mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
361538aabc | ||
|
|
5eec80bbca | ||
|
|
40c189c431 | ||
|
|
bc8b165ad9 | ||
|
|
61f0974c61 | ||
|
|
769d04471f | ||
|
|
da294aaa2b | ||
|
|
1226d44872 | ||
|
|
ac4ca9a3ae | ||
|
|
597db3287e | ||
|
|
bbc57f9c0d | ||
|
|
9ab9b550d1 | ||
|
|
49ca9067a9 | ||
|
|
805a58e32f | ||
|
|
d09b0a1c2d | ||
|
|
92b1810c07 | ||
|
|
3ad37c190b | ||
|
|
cb56cedb1f | ||
|
|
20b4ea6a07 | ||
|
|
0511e5754b | ||
|
|
3800e736d0 | ||
|
|
7548ea75d4 | ||
|
|
14d0762245 | ||
|
|
a990989d34 | ||
|
|
3b9ea333ad | ||
|
|
86d612ec93 | ||
|
|
0c5d4a72b2 | ||
|
|
61e8c696c9 | ||
|
|
f6751b70d4 | ||
|
|
207486a53d | ||
|
|
aab4dd3bfd | ||
|
|
9dd6b40934 | ||
|
|
dcc676bd52 | ||
|
|
8ff8a5fb26 | ||
|
|
9707f307a1 | ||
|
|
02d994c53b | ||
|
|
0a3f4e2a57 | ||
|
|
03acb75f66 | ||
|
|
a71baa49cd | ||
|
|
89f3d84234 | ||
|
|
182b0c49c2 | ||
|
|
2d6135121e | ||
|
|
3b90dae767 | ||
|
|
236357facb | ||
|
|
9ae1c22af0 | ||
|
|
edef002beb | ||
|
|
fd36389328 | ||
|
|
a84049b8fc | ||
|
|
c094afad65 | ||
|
|
0a4ff1e6ae | ||
|
|
32587c8063 | ||
|
|
3aca5bd1e0 | ||
|
|
f747b33b46 | ||
|
|
45a1621909 | ||
|
|
2f4de543a9 | ||
|
|
345cf7918b | ||
|
|
8892c6dc43 | ||
|
|
f9e7002048 | ||
|
|
585bb04ab2 | ||
|
|
31bc23386b | ||
|
|
112cf84362 | ||
|
|
61ede5ee99 | ||
|
|
2fbde04563 | ||
|
|
9c118d41c2 | ||
|
|
c0c5e1cfcb | ||
|
|
28b10eee2b | ||
|
|
23868ef7c9 | ||
|
|
10c667dffd | ||
|
|
11aa82b459 | ||
|
|
9658e167ba | ||
|
|
e71db32885 | ||
|
|
c6166de886 | ||
|
|
1214108d6b | ||
|
|
4f66c72121 | ||
|
|
d3dc84028d | ||
|
|
591dedab2f | ||
|
|
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
|
||||||
|
|
||||||
+41
-1
@@ -29,6 +29,14 @@ BETTER_AUTH_SECRET=change-me-super-long-better-auth-secret
|
|||||||
BETTER_AUTH_URL=http://localhost/auth
|
BETTER_AUTH_URL=http://localhost/auth
|
||||||
# Comma-separated emails promoted to auth admin on boot (Authentication → Users panel)
|
# Comma-separated emails promoted to auth admin on boot (Authentication → Users panel)
|
||||||
AUTH_ADMIN_EMAILS=
|
AUTH_ADMIN_EMAILS=
|
||||||
|
# The first public sign-up always becomes the instance admin. With no users the
|
||||||
|
# login page shows only the sign-up form. After bootstrap, public sign-up is
|
||||||
|
# closed unless SIGNUP_ENABLED=true or the admin enables it in-app
|
||||||
|
# (Settings → Instance). In-app values override this file.
|
||||||
|
SIGNUP_ENABLED=false
|
||||||
|
# Managed tier flag — OAuth providers activate only when this is "true".
|
||||||
|
# Self-hosted instances are email/password regardless of the keys below.
|
||||||
|
PRIMORA_MANAGED=false
|
||||||
COOKIE_DOMAIN=localhost
|
COOKIE_DOMAIN=localhost
|
||||||
|
|
||||||
FRONTEND_PORT=3000
|
FRONTEND_PORT=3000
|
||||||
@@ -42,14 +50,46 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1
|
|||||||
BACKEND_INTERNAL_URL=http://backend:8080
|
BACKEND_INTERNAL_URL=http://backend:8080
|
||||||
BACKEND_STORAGE_ROOT=/data/storage
|
BACKEND_STORAGE_ROOT=/data/storage
|
||||||
|
|
||||||
|
# Object storage driver: "local" (default, uses BACKEND_STORAGE_ROOT) or "s3".
|
||||||
|
# For s3, point S3_ENDPOINT at any S3-compatible API (AWS, MinIO, Garage, R2).
|
||||||
|
BACKEND_STORAGE_DRIVER=local
|
||||||
|
#S3_ENDPOINT=http://minio:9000
|
||||||
|
#S3_REGION=us-east-1
|
||||||
|
#S3_BUCKET=primora-objects
|
||||||
|
#S3_ACCESS_KEY_ID=
|
||||||
|
#S3_SECRET_ACCESS_KEY=
|
||||||
|
#S3_PREFIX=
|
||||||
|
#S3_PATH_STYLE=true
|
||||||
|
# Client-facing S3 endpoint for presigned URLs — set when S3_ENDPOINT is an
|
||||||
|
# internal address (e.g. http://minio:9000 inside compose) that browsers
|
||||||
|
# can't reach. Presigned URLs are valid only at the host they were minted for.
|
||||||
|
#S3_PUBLIC_ENDPOINT=https://objects.example.com
|
||||||
|
|
||||||
|
# Functions execution. "exec" runs the runtime binary (bun/deno) directly on
|
||||||
|
# the backend host — simple, but function code inherits backend privileges.
|
||||||
|
# "docker" runs each invoke in an ephemeral locked-down container (no
|
||||||
|
# network, read-only fs, 128 MiB mem, non-root) and requires the Docker
|
||||||
|
# socket mounted into the backend plus the runtime images pulled.
|
||||||
|
#FUNCTIONS_DRIVER=exec
|
||||||
|
#FUNCTIONS_RUNTIME=auto
|
||||||
|
#FUNCTIONS_TIMEOUT_SECONDS=30
|
||||||
|
#FUNCTIONS_MAX_OUTPUT_BYTES=65536
|
||||||
|
|
||||||
|
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
|
||||||
|
# values win over these env vars; deleting the in-app value reverts here.
|
||||||
|
# With no transport configured (no Resend key and no SMTP_HOST) accounts are
|
||||||
|
# verified automatically and no verification mail is attempted.
|
||||||
|
# The dev overlay (docker compose -f docker-compose.yml -f docker-compose.dev.yml)
|
||||||
|
# runs Mailpit — then SMTP_HOST=mailpit catches all outgoing mail at :8025.
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
MAIL_FROM=Primora <[email protected]>
|
MAIL_FROM=Primora <[email protected]>
|
||||||
SMTP_HOST=mailpit
|
SMTP_HOST=
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
|
|
||||||
|
# OAuth keys only take effect when PRIMORA_MANAGED=true (managed tier).
|
||||||
GITHUB_CLIENT_ID=
|
GITHUB_CLIENT_ID=
|
||||||
GITHUB_CLIENT_SECRET=
|
GITHUB_CLIENT_SECRET=
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
|
|||||||
+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
|
||||||
+221
-19
@@ -1,43 +1,82 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Triggered by semver tags: git tag v0.3.0 && git push --tags
|
# Triggered two ways:
|
||||||
# Tag must match the root package.json version.
|
# 1. Semver tags: git tag v0.3.0 && git push --tags (must match package.json).
|
||||||
|
# 2. workflow_run: after the CI workflow succeeds on master, the version in
|
||||||
|
# root package.json is released automatically if no matching tag exists.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["CI"]
|
||||||
|
types: [completed]
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
env:
|
|
||||||
VERSION: ${{ github.ref_name }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
name: Verify tag
|
name: Verify version
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.check.outputs.version }}
|
||||||
|
needed: ${{ steps.check.outputs.needed }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Check tag matches package version
|
- name: Resolve version + decide whether to release
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_run" ]; then
|
||||||
|
if [ "${{ github.event.workflow_run.conclusion }}" != "success" ]; then
|
||||||
|
echo "CI did not succeed — skipping release."
|
||||||
|
echo "needed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
VERSION="v$(node -p "require('./package.json').version")"
|
||||||
|
if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" > /dev/null 2>&1; then
|
||||||
|
echo "$VERSION already released — nothing to do."
|
||||||
|
echo "needed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
VERSION="${{ github.ref_name }}"
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "needed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Check tag matches package versions
|
||||||
|
if: steps.check.outputs.needed == 'true'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.check.outputs.version }}
|
||||||
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
|
||||||
|
|
||||||
images:
|
images:
|
||||||
name: Build + push ${{ matrix.app }}
|
name: Build + push ${{ matrix.app }} (${{ matrix.platform }})
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
if: needs.verify.outputs.needed == 'true'
|
||||||
|
# Native runners per arch — QEMU emulation SIGILLs on the current
|
||||||
|
# node:20-alpine arm64 layers (uncaught target signal 4) and wedges
|
||||||
|
# the build step indefinitely.
|
||||||
|
runs-on: ${{ matrix.platform == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
app: [backend, auth, frontend]
|
app: [backend, auth, frontend]
|
||||||
|
platform: [amd64, arm64]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- 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,16 +87,43 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: apps/${{ matrix.app }}/Dockerfile
|
file: apps/${{ matrix.app }}/Dockerfile
|
||||||
|
platforms: linux/${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }}-${{ matrix.platform }}
|
||||||
ghcr.io/${{ github.repository_owner }}/primora-${{ matrix.app }}:${{ env.VERSION }}
|
cache-from: type=gha,scope=${{ matrix.app }}-${{ matrix.platform }}
|
||||||
ghcr.io/${{ github.repository_owner }}/primora-${{ matrix.app }}:latest
|
cache-to: type=gha,mode=max,scope=${{ matrix.app }}-${{ matrix.platform }}
|
||||||
cache-from: type=gha,scope=${{ matrix.app }}
|
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.app }}
|
# Joins the per-arch images into the real version + latest tags.
|
||||||
|
manifests:
|
||||||
|
name: Image manifests
|
||||||
|
needs: [verify, images]
|
||||||
|
if: needs.verify.outputs.needed == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- 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: Create multi-arch manifests
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.verify.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
for app in backend auth frontend; do
|
||||||
|
img="ghcr.io/${GHCR_OWNER}/primora-${app}"
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t "$img:$VERSION" -t "$img:latest" \
|
||||||
|
"$img:$VERSION-amd64" "$img:$VERSION-arm64"
|
||||||
|
done
|
||||||
|
|
||||||
cli-binaries:
|
cli-binaries:
|
||||||
name: Compile ${{ matrix.name }}
|
name: Compile ${{ matrix.name }}
|
||||||
needs: verify
|
needs: verify
|
||||||
|
if: needs.verify.outputs.needed == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -87,6 +153,7 @@ jobs:
|
|||||||
desktop:
|
desktop:
|
||||||
name: Desktop bundle (${{ matrix.name }})
|
name: Desktop bundle (${{ matrix.name }})
|
||||||
needs: verify
|
needs: verify
|
||||||
|
if: needs.verify.outputs.needed == 'true'
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -107,6 +174,11 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
- name: Build Tauri bundles
|
- name: Build Tauri bundles
|
||||||
|
# Updater artifacts are minisign-signed; without the key the build
|
||||||
|
# still succeeds but emits no .sig files (updater can't verify them).
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
run: npm run bundle --workspace @primora/desktop
|
run: npm run bundle --workspace @primora/desktop
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -116,13 +188,17 @@ jobs:
|
|||||||
apps/desktop/src-tauri/target/release/bundle/**/*.deb
|
apps/desktop/src-tauri/target/release/bundle/**/*.deb
|
||||||
apps/desktop/src-tauri/target/release/bundle/**/*.rpm
|
apps/desktop/src-tauri/target/release/bundle/**/*.rpm
|
||||||
apps/desktop/src-tauri/target/release/bundle/**/*.dmg
|
apps/desktop/src-tauri/target/release/bundle/**/*.dmg
|
||||||
|
apps/desktop/src-tauri/target/release/bundle/**/*.app.tar.gz
|
||||||
apps/desktop/src-tauri/target/release/bundle/**/*.msi
|
apps/desktop/src-tauri/target/release/bundle/**/*.msi
|
||||||
apps/desktop/src-tauri/target/release/bundle/**/*.exe
|
apps/desktop/src-tauri/target/release/bundle/**/*.exe
|
||||||
|
apps/desktop/src-tauri/target/release/bundle/**/*.nsis.zip
|
||||||
|
apps/desktop/src-tauri/target/release/bundle/**/*.sig
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
npm-packages:
|
npm-packages:
|
||||||
name: Pack npm tarballs
|
name: Pack npm tarballs
|
||||||
needs: verify
|
needs: verify
|
||||||
|
if: needs.verify.outputs.needed == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -144,15 +220,39 @@ jobs:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
name: GitHub Release
|
name: GitHub Release
|
||||||
needs: [images, cli-binaries, desktop, npm-packages]
|
needs: [verify, manifests, cli-binaries, desktop, npm-packages]
|
||||||
|
if: needs.verify.outputs.needed == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.verify.outputs.version }}
|
||||||
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
|
||||||
|
# workflow_run runs leave the release untagged — create it so the
|
||||||
|
# GitHub Release points at a real tag. GITHUB_TOKEN pushes do not
|
||||||
|
# re-trigger workflows, so this cannot recurse.
|
||||||
|
- name: Tag release
|
||||||
|
if: github.event_name == 'workflow_run'
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git tag "$VERSION"
|
||||||
|
git push origin "$VERSION"
|
||||||
- name: Extract changelog section
|
- name: Extract changelog section
|
||||||
run: |
|
run: |
|
||||||
awk -v ver="${VERSION#v}" '
|
awk -v ver="${VERSION#v}" '
|
||||||
@@ -161,10 +261,112 @@ jobs:
|
|||||||
flag
|
flag
|
||||||
' CHANGELOG.md > notes.md
|
' CHANGELOG.md > notes.md
|
||||||
[ -s notes.md ] || echo "Release ${VERSION}" > notes.md
|
[ -s notes.md ] || echo "Release ${VERSION}" > notes.md
|
||||||
|
# Tauri updater manifest: one platform entry per signed updater
|
||||||
|
# artifact (AppImage.sig / app.tar.gz.sig / nsis.zip.sig). Missing
|
||||||
|
# sigs mean TAURI_SIGNING_PRIVATE_KEY isn't set — then there is no
|
||||||
|
# manifest and no update, which is correct (never ship unsigned).
|
||||||
|
- name: Build updater manifest (latest.json)
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, os, glob
|
||||||
|
base = "https://github.com/Dvorinka/Primora/releases/download/%s" % os.environ["VERSION"]
|
||||||
|
platforms = {}
|
||||||
|
for sig in glob.glob("dist-release/**/*.sig", recursive=True):
|
||||||
|
asset = sig[:-4]
|
||||||
|
name = os.path.basename(asset)
|
||||||
|
if name.endswith(".AppImage"):
|
||||||
|
plat = "linux-x86_64"
|
||||||
|
elif name.endswith(".app.tar.gz"):
|
||||||
|
plat = "darwin-aarch64" if "aarch64" in name else "darwin-x86_64"
|
||||||
|
if "aarch64" not in name and "x86_64" not in name:
|
||||||
|
plat = "darwin-aarch64" # macos-latest is arm64
|
||||||
|
elif name.endswith(".nsis.zip"):
|
||||||
|
plat = "windows-x86_64"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
platforms[plat] = {
|
||||||
|
"signature": open(sig).read().strip(),
|
||||||
|
"url": f"{base}/{name}",
|
||||||
|
}
|
||||||
|
manifest = {
|
||||||
|
"version": os.environ["VERSION"],
|
||||||
|
"notes": "See the release notes.",
|
||||||
|
"pub_date": __import__("datetime").datetime.now(
|
||||||
|
__import__("datetime").timezone.utc
|
||||||
|
).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"platforms": platforms,
|
||||||
|
}
|
||||||
|
if platforms:
|
||||||
|
with open("dist-release/latest.json", "w") as f:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
print("latest.json:", ", ".join(platforms))
|
||||||
|
else:
|
||||||
|
print("no signed updater artifacts — skipping latest.json")
|
||||||
|
PY
|
||||||
- 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[@]}"
|
||||||
|
# The manifest must be fetchable at /releases/latest/download/latest.json
|
||||||
|
if [ -f dist-release/latest.json ]; then
|
||||||
|
gh release upload "$VERSION" dist-release/latest.json --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Software bill of materials — CycloneDX JSON per published image plus
|
||||||
|
# one for the source tree (npm + Go modules). Attached to the release
|
||||||
|
# next to the binaries; downstream consumers can verify provenance.
|
||||||
|
sbom:
|
||||||
|
name: SBOMs
|
||||||
|
needs: [verify, release]
|
||||||
|
if: needs.verify.outputs.needed == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: read
|
||||||
|
# contents: write — the job uploads SBOM assets onto the release.
|
||||||
|
contents: write
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.verify.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# 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 }}
|
||||||
|
# anchore/sbom-action does not create the output directory.
|
||||||
|
- run: mkdir -p dist-release
|
||||||
|
- uses: anchore/sbom-action@v0
|
||||||
|
with:
|
||||||
|
image: ghcr.io/${{ env.GHCR_OWNER }}/primora-backend:${{ needs.verify.outputs.version }}
|
||||||
|
format: cyclonedx-json
|
||||||
|
output-file: dist-release/sbom-backend.cdx.json
|
||||||
|
- uses: anchore/sbom-action@v0
|
||||||
|
with:
|
||||||
|
image: ghcr.io/${{ env.GHCR_OWNER }}/primora-auth:${{ needs.verify.outputs.version }}
|
||||||
|
format: cyclonedx-json
|
||||||
|
output-file: dist-release/sbom-auth.cdx.json
|
||||||
|
- uses: anchore/sbom-action@v0
|
||||||
|
with:
|
||||||
|
image: ghcr.io/${{ env.GHCR_OWNER }}/primora-frontend:${{ needs.verify.outputs.version }}
|
||||||
|
format: cyclonedx-json
|
||||||
|
output-file: dist-release/sbom-frontend.cdx.json
|
||||||
|
- uses: anchore/sbom-action@v0
|
||||||
|
with:
|
||||||
|
path: .
|
||||||
|
format: cyclonedx-json
|
||||||
|
output-file: dist-release/sbom-source.cdx.json
|
||||||
|
- name: Attach SBOMs to the release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: gh release upload "$VERSION" dist-release/sbom-*.cdx.json --clobber
|
||||||
|
|
||||||
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
|
||||||
@@ -37,3 +39,5 @@ infra/mailpit
|
|||||||
# Generated code-graph artifacts
|
# Generated code-graph artifacts
|
||||||
graphify-out/
|
graphify-out/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
# Local brag/launch-video output
|
||||||
|
brag-output/
|
||||||
+147
@@ -4,6 +4,153 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.8.4] - 2026-09-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Slugs auto-derive from names** — typing an organization, project, bucket, or collection name now fills its slug (`TDvorak` → `tdvorak`); hand-editing the slug stops the derivation, and manual edits are sanitized as you type. Edit forms stay manual.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Installer URL prompt was invisible under `curl | bash`** — `exec 9</dev/tty 2>/dev/null` sent the whole shell's stderr to `/dev/null` permanently, so the `read` prompt rendered nowhere while the script blocked on input. The prompt now reads `/dev/tty` directly with a quiet probe; no-tty runs default silently.
|
||||||
|
- **Installer fetches fail fast** — the compose-file and source-tarball `curl` calls now carry connect/max-time timeouts plus retries, so a stalled GitHub connection errors within seconds naming the file instead of hanging forever.
|
||||||
|
|
||||||
|
## [0.8.3] - 2026-09-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Install script asks for the public URL** — `install.sh` now prompts for where you'll open the app and accepts a full URL: `https://primora.example.com` (TLS at your proxy, hints printed at the end), `primora.example.com`, or `192.168.1.50:8085` (the port becomes `NGINX_PORT`). The answer rewrites every public URL var in `.env` and `COOKIE_DOMAIN` for real DNS names. The prompt reads the controlling terminal so it appears under `curl | bash`; no tty (CI, cron) falls back to `localhost`. Non-interactive via `PUBLIC_URL=`; legacy `DOMAIN`/`NGINX_PORT` envs still work; reruns over an existing `.env` skip the question.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Auth origins behind external TLS terminators** — trusted origins now accept both URL schemes on the serving host. Verified live over a Cloudflare quick tunnel: a real browser session cookie + `https://*.trycloudflare.com` origin was 403'd on v0.8.2 (nginx reports `X-Forwarded-Proto: http` inbound); the same request now signs in cleanly while cross-site origins still 403.
|
||||||
|
|
||||||
|
## [0.8.2] - 2026-09-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Self-hosted sign-up works on any origin** — trusted origins are now derived from the request's public host (forwarded by nginx), so browsing the instance via a LAN IP, hostname, or custom port no longer fails every auth POST with a 403. Static `VITE_APP_URL`/`AUTH_BASE_URL` origins and `BETTER_AUTH_TRUSTED_ORIGINS` still apply.
|
||||||
|
- **Sign-up/sign-in failures are surfaced, not swallowed** — better-auth resolves `{ data, error }` instead of throwing, so a failed request previously showed "Account created — signing you in" and bounced back to the bootstrap screen. The real server error (invalid origin, rate limit, disabled sign-up) is now displayed.
|
||||||
|
- **Mail probe can no longer break auth** — `hasMailTransport` degrades to "no transport" instead of throwing when `core.settings` is unreadable, and the no-transport startup backfill is best-effort. Previously, auth could crash-loop while the backend was still migrating on a fresh volume — every sign-up 502'd during the window.
|
||||||
|
|
||||||
|
## [0.8.1] - 2026-09-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **SMTP-less instances no longer involve email at all** — when no mail transport is configured (no Resend key, no `SMTP_HOST`), new accounts are created `emailVerified=true`, the verification send is skipped entirely, and existing unverified users are marked verified on startup. `.env.example` no longer defaults `SMTP_HOST` to the dev-only `mailpit` service, which left production deploys pointing at a transport that does not exist.
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-09-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Agent setup prompt** — a "Set up with an AI agent" dialog that mints a fresh `write`-scoped API key (ingest + reads + non-admin mutations, never key-minting) and renders a copyable prompt handing a coding agent everything it needs: the deployment endpoint, project id, the key itself, plus tasks to instrument error/metric/log/heartbeat reporting per component (`POST /api/v1/ingest` — works from any language), register the app's databases via `db-connections` (postgres → cassandra), wire the MCP server / CLI for future agents, and verify with a test event + deploy marker. Opens at the end of the new-project onboarding and is re-reachable from the project dashboard and Projects page; keys are shown only inside the prompt (revoke under Settings → API keys).
|
||||||
|
- **Local secrets vault in the CLI** (`@primora/cli` 0.6.0) — `primora vault init/status` and `primora secrets set|get|list|rm|import` manage an encrypted `~/.config/primora/vault.bin` (Argon2id 64 MiB/3/4 → XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes). Entries carry value + optional `url`/`notes` metadata. Password via `PRIMORA_VAULT_PASSWORD`, `--password-file`, or hidden prompt; file path overridable with `PRIMORA_VAULT`. Clean-room format — no external vault compatibility.
|
||||||
|
- **Vault sessions** — `primora vault unlock --ttl 900` proves the password once and writes a TTL'd `vault.session` (0600); agents and scripts use the vault without the password. `primora vault lock` revokes immediately.
|
||||||
|
- **`primora agent` credential broker** — wraps a child process with dummy tokens and a loopback base URL; the proxy attaches the real secret on the wire per grant (TTL, cap 3600 s). Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`, `github`, `npm`; `--upstream name=https://…` covers arbitrary Bearer APIs. Base-URL broker, not TLS MITM — tools must honor a base-URL override.
|
||||||
|
- **`primora inject`** — runs a child process with vault secrets in its environment (`--all`, or `--env-file` templates containing `primora://NAME` refs). Nothing on disk, nothing in argv.
|
||||||
|
- **`primora stack:*` operator group** — `up`, `down`, `status`, `logs [service]`, `pull`, `backup` wrap `docker compose` in `--dir` (default cwd). `stack up --vault` materializes `.env` from the vault for the duration of the run (refuses to clobber an existing `.env` without `--force`, which swaps and restores it). `stack backup` writes a `pg_dump -Fc` of the platform database to `./backups`.
|
||||||
|
- **Vault MCP tools** (`@primora/mcp`) — `primora_vault_status`, `primora_vault_list` (metadata only — values are never returned), and `primora_vault_exec` which runs `primora` commands with a secret injected into the child environment; `vault`/`secrets`/`inject`/`agent`/`login`/`logout`/`use` and `keys:create` are denied so no response can carry a credential. (Hardened to a strict allow-list — see Security.)
|
||||||
|
- **Secrets Vault window in the desktop app** (`@primora/desktop`) — tray "Secrets Vault…" opens a dedicated window (`ui/vault.html`) covering init, timed unlock/lock with live countdown, secret list with copy-to-clipboard (values never displayed), add/delete, and `.env` import. IPC shells out to the `primora` binary (`PRIMORA_CLI` overrides), so desktop and terminal share one vault file and one crypto implementation; a missing CLI shows install guidance. Also reachable from the connect screen.
|
||||||
|
- **Project vault — server-side secrets** (`core.project_secrets`, migration 00011) — per-project secrets encrypted AES-256-GCM at rest via the existing encryptor. API: `GET/PUT/DELETE /projects/:projectID/secrets[/:name]` plus `POST …/reveal`. Lists return metadata only (name, url, notes, timestamps); reveal decrypts explicitly and is audit-logged (`secret.set`/`secret.revealed`/`secret.deleted`). Scheduled-job payloads reference secrets as `secret://NAME`, resolved to plaintext at delivery — refs are stored, values are not; unresolved or unconfigured refs fail the run loudly.
|
||||||
|
- **Vault page in the dashboard** — project secrets table with create/update, `.env` import, delete, and copy-to-clipboard reveal; plus a "This machine" card when running inside the desktop shell that drives the local vault through the same Tauri IPC commands as the vault window.
|
||||||
|
- **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values.
|
||||||
|
- **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext.
|
||||||
|
- **Realtime channel subscriptions** (`@primora/client`) — `createRealtimeClient({ endpoint, key, projectId })` opens the project SSE stream (`/realtime/stream`) via fetch + ReadableStream (EventSource can't send `X-API-Key`). `channel("documents").on("created", cb)` subscribes by event namespace (`document`/`object`/`issue`/`deploy`, `"*"` for all); auto-reconnects with capped exponential backoff; `onStateChange` reports `connecting|open|reconnecting|closed`.
|
||||||
|
- **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components.
|
||||||
|
- **Inbound webhooks** (`core.inbound_hooks`, migration 00013) — public ingest endpoints `POST /api/v1/hooks/<token>` (the token is the credential). `event` mode republishes the body as `inbound.received` domain events through the signed dispatcher + realtime; `job` mode triggers a scheduled-job run with the received body as its payload (`triggered_by: "hook"`). Optional `secret` requires `X-Primora-Signature` HMAC-SHA256 — the same scheme outbound deliveries use. Bodies capped at 256 KiB. Managed from Integrations → Webhooks.
|
||||||
|
- **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection <slug> --filter … --order …`.
|
||||||
|
- **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test.
|
||||||
|
- **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations.
|
||||||
|
- **S3-compatible object storage backend** — `BACKEND_STORAGE_DRIVER=s3` swaps the local disk store for any S3-compatible API (AWS, MinIO, Garage, R2) via a stdlib-only SigV4 implementation — no AWS SDK dependency. `S3_ENDPOINT`/`S3_REGION`/`S3_BUCKET`/credentials configure it; `S3_PATH_STYLE` (default on) covers MinIO/Garage, off for AWS virtual-hosted. The configured bucket is created on startup if missing; objects stream with computed SHA-256, `storage_path` records `s3://bucket/key`. Verified live end-to-end (bucket → put → get → copy → list → delete).
|
||||||
|
- **Functions** (`core.functions` + `core.function_runs`, migration 00015) — project-scoped JS/TS executed by a runtime binary on the backend host (`bun` or `deno`, per function). The JSON payload arrives on stdin and as `PRIMORA_PAYLOAD`; stdout/stderr are captured per run (capped at 64 KiB) with status, exit code and duration — `FUNCTIONS_RUNTIME`/`FUNCTIONS_TIMEOUT_SECONDS`/`FUNCTIONS_MAX_OUTPUT_BYTES` configure the runner. API: CRUD under `/projects/:id/functions`, `POST …/invoke` (502 + recorded run on failure), `GET …/runs`. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Code executes with backend privileges — the documented self-hosted trust model.
|
||||||
|
- **Presigned object URLs** — `POST /buckets/:bucketID/object-presigns` mints SigV4 query-signed `GET`/`PUT` URLs (60 s–1 h TTL) so clients transfer large objects directly against S3 without proxying through the backend. Read role for downloads (object must exist — no key probing), write role for uploads. `S3_PUBLIC_ENDPOINT` rewrites the client-facing host for internal `S3_ENDPOINT`s; signatures bind the host so URLs are valid only where they were minted. The local driver returns a clear error — a presigned URL needs a real object API.
|
||||||
|
- **Desktop auto-updater** — `tauri-plugin-updater` wired end to end: minisign keypair, `createUpdaterArtifacts`, `latest.json` manifest built from signed artifacts on every release, and a silent background check on the connect screen offering "Update & restart" (download → verify → install → relaunch). Unsigned artifacts can never install — missing signature means no manifest, not a bypass. CI signs via `TAURI_SIGNING_PRIVATE_KEY` secrets; OS-level signing (notarization/Authenticode) remains open.
|
||||||
|
- **Release SBOMs** — CycloneDX SBOM for each published GHCR image (backend/auth/frontend) plus one for the source tree, attached to every GitHub Release via `anchore/sbom-action`.
|
||||||
|
- **Sandboxed function driver** — `FUNCTIONS_DRIVER=docker` runs every invoke in an ephemeral locked-down container: no network, read-only rootfs, 128 MiB memory, 0.5 CPU, 64 pids, non-root, all capabilities dropped. Source is injected via base64 env (no bind mount — works on Docker Desktop and remote daemons); timeout force-removes the container so nothing lingers. Verified live: code runs, network is unreachable inside, and deadline kills land.
|
||||||
|
- **Client events + presence** — `POST /projects/:id/events` publishes client-authored events (`custom.*` namespace enforced — system types can't be spoofed) through the same fan-out as system events: realtime subscribers, matching webhooks, and `event_pattern` functions all fire. Realtime now tracks presence: `presence.update` {online} broadcasts on every subscribe/unsubscribe (stream-only — never reaches webhooks or functions), `GET …/realtime/presence` reads the count. Client SDK gains `publish()` and `presence()`; CLI gains `events:send`.
|
||||||
|
- **Function triggers** (migration 00016) — three new ways to run a function, all through the same executor and run log. Scheduled jobs accept `function_id` instead of `url` (run status mirrors the exit code); inbound hooks gain `mode: "function"` — the received body becomes the function's stdin payload, acked immediately with the run recorded async; and `event_pattern` on a function (`document.*`, `*`, or an exact type) fires it detached whenever a matching domain event publishes — `{event, project_id, occurred_at, data}` as payload. Runs record trigger source: `manual`/`schedule`/`hook`/`event`. Dashboard: function event-trigger field, job target picker (URL or function), and inbound-hook function mode.
|
||||||
|
- **`GET /api/v1/context`** — actor self-discovery: for API keys returns scopes, key prefix, and the resolved organization + project (id/slug/name). Fixes the API-key onboarding dead end: `primora login --api-key` now validates the key against the instance before saving and seeds org/project context automatically; `primora use`, `context`, and `whoami` work under API-key auth via this endpoint.
|
||||||
|
- **CLI `orgs:rm` / `projects:rm` / `buckets:rm`** — destructive deletes gated behind `--yes` in non-interactive mode, interactive confirm otherwise. `buckets:rm` resolves id/slug/name.
|
||||||
|
- **Webhook wildcard subscriptions** — `events` accepts `*`, `<namespace>.*`, and `custom.*` patterns, matched at dispatch time via SQL `LIKE` — the same semantics function `event_pattern` already used. Client-published `custom.*` events can now be webhooked.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **`primora_vault_exec` rewritten as an allow-list** — the deny-list was bypassed by colon-form commands (`["secrets:get","X"]` as a single token evaded the pair check), exposing every vault secret; `--remote` variants revealed project secrets; and the `secret` param was used verbatim as an env name, allowing `NODE_OPTIONS=--require <file>` host code execution. Now only a fixed read-mostly command set runs (`whoami`, `context`, `orgs:list`, `projects:list`, `buckets:list`, `objects:list`, `documents:list`, `jobs:list`, `jobs:runs`, `audit:list`, `keys:list`, `events:send`), path/file flags and `..` args are rejected, injected env names must match `^[A-Z_][A-Z0-9_]{0,63}$`, and loader-hook names (`NODE_*`, `LD_*`, `DYLD_*`, `PATH`, `BASH*`, `PYTHON*`, `GIT_*`, `SSL_CERT*`, `XDG_*`, …) are blocked. Verified: every prior bypass denied, allowed commands still inject.
|
||||||
|
- **SSRF hardening on webhook/job URLs** — cloud metadata endpoints (`169.254.169.254`, `fd00:ec2::254`) are excluded from the link-local/private allowance, and pure decimal/hex "bare hostnames" (`2130706433`, `0x7f000001` — non-canonical IP encodings of loopback) are no longer treated as Docker service names.
|
||||||
|
- **Path params now `encodeURIComponent`** in all generated-client consumers (CLI, MCP, dashboard) — `encodeURI` left `?#&` intact, smuggling query segments through `{name}` params (e.g. `secrets/a?reveal=1`).
|
||||||
|
- **Strict base64 in `primora_upload_object`** — `Buffer.from(b64)` silently drops invalid characters; `contentBase64` is now validated (whitespace-tolerant) so corrupt input can't silently store garbage bytes.
|
||||||
|
- **MCP no-creds startup** — a missing config/API key previously threw inside `configureClient`, killing the process mid-handshake (protocol hang, stack on stderr). Startup now degrades: `initialize`/`tools/list` answer normally and every tool call returns the actionable error.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Silent CLI dispatch** — unknown commands, a bare `primora`, and group names without a verb exited 0 with no output. Now: unknown input errors with exit 1 (bare groups list their verbs), bare `primora` prints help.
|
||||||
|
- **Arg-parser numeric coercion** — `cac`/`mri` coerced numeric-looking flag values (`--value "007"` → `7`, `--value ""` → `0`), corrupting secrets and keys. Parsed options are now re-read as raw strings from argv for every non-boolean flag; `--value 007` round-trips intact.
|
||||||
|
- **`pgx.ErrNoRows` → 404** in the shared error mapper — missing resources returned raw "no rows in result set" 500s across all handlers.
|
||||||
|
- **Structured error hygiene** — `pgconn.PgError` codes map to clean statuses (23505 → 409 "resource already exists", 23503 → 409, 22001/54000 → 400 "value too large", 22021 → 400) instead of leaking SQLSTATE/constraint names; validator failures report `field is required`/`field failed <tag> validation`; a new `services.InputError` type maps service-layer input failures to 400 (webhook URL/event-type errors were 500s). Presign on the local driver returns `presign_unsupported` 400.
|
||||||
|
- **`fetch failed` polish** — connection failures report the cause code and suggest `primora context` instead of bare undici errors.
|
||||||
|
- **`login --json`** — the flag now emits JSON on both the session and API-key paths.
|
||||||
|
- **Dashboard fonts vendored** — Geist/Geist Mono now ship via `@fontsource` inside the bundle instead of a Google Fonts `<link>` the nginx CSP blocked (`style-src 'self'`). Removes the runtime Google dependency, works airgapped, and eliminates the console CSP violation.
|
||||||
|
- **Release image builds moved off QEMU** — arm64 `npm ci` under `docker/setup-qemu-action` SIGILLed on current `node:20-alpine` layers (`uncaught target signal 4`) and wedged the build step for over an hour. Images now build per-arch on native runners (`ubuntu-24.04-arm` for arm64) and a `manifests` job joins them into the multi-arch `:<version>`/`:latest` tags.
|
||||||
|
|
||||||
|
## [0.7.1] - 2026-09-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Self-hosted sign-up no longer requires mail infrastructure** — `sendTransactionalEmail` warned-and-returned when no transport was configured instead of throwing, so SMTP-less instances 500'd on sign-up. `sendVerificationEmail` now also catches transport failures; a broken SMTP cannot block account creation (verification stays optional).
|
||||||
|
- **Password managers detect the login form** — the email/password fields gained `id`/`name` attributes and `label for`/`id` pairing, and the email input uses `autocomplete="username"` (Proton Pass et al.).
|
||||||
|
- **Installer prebuilt path strips build blocks** — `install.sh` previously kept `*-build` compose blocks when pulling prebuilt GHCR images.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Static landing page** (`landing/`) — self-contained marketing site with bundled Geist fonts and product screenshots; deployed separately from the app artifacts.
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- **Automatic releases on merge** — merges to `master` whose root `package.json` version has no matching `v*` tag now trigger the full release pipeline (GHCR images, CLI binaries, desktop bundles, npm tarballs, GitHub Release). Tag pushes still work as before.
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-09-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Public sign-up closes by default after the first account.** On upgrade, existing instances keep their users but new public sign-ups are rejected until an admin enables them (Settings → Instance or `SIGNUP_ENABLED=true`). The oldest existing account is not automatically promoted — set `AUTH_ADMIN_EMAILS` or update `user.role` to `admin` manually if no platform admin exists.
|
||||||
|
- **OAuth providers require `PRIMORA_MANAGED=true`.** Self-hosted deployments are email/password only; GitHub/Google/Discord/Microsoft env keys are ignored without the flag.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Instance administration** — `core.settings` (migration 00010) stores in-app overrides that resolve ahead of `.env` and built-in defaults. New endpoints: `GET /instance/public` (unauthenticated login-page state), `GET/PUT/DELETE /instance/settings[/key]` (platform admin only — better-auth `user.role`, not org role). Manageable keys cover public sign-up, mail transport (Resend or SMTP), and API/user rate limits. Secrets are AES-256-GCM encrypted at rest, never returned by the API, and decryptable by the auth service via the shared `PRIMORA_ENCRYPTION_KEY`.
|
||||||
|
- **Bootstrap + sign-up gating** — the first public sign-up becomes the instance admin; afterwards public sign-up is closed unless `SIGNUP_ENABLED=true` or the admin reopens it in Settings → Instance. `databaseHooks.user.create.before` gates only `/sign-up/email`, so admin-created users and OAuth flows bypass it.
|
||||||
|
- **Managed-tier OAuth gate** — `PRIMORA_MANAGED=true` is now required for GitHub/Google/Discord/Microsoft sign-in; self-hosted instances are email/password only. The login page hides the sign-up tab and social buttons according to `GET /instance/public`.
|
||||||
|
- **Settings → Instance tab** — platform admins edit sign-up, mail, and rate-limit settings in-app with source badges (in-app/env/default) and per-key reset.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+35
-3
@@ -9,9 +9,24 @@ cp .env.example .env
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
For native development run only the infrastructure in Docker
|
For native development run only the infrastructure in Docker and start the
|
||||||
(`docker compose up -d postgres dragonfly mailpit`) and start the three
|
three services yourself:
|
||||||
services yourself.
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres dragonfly mailpit # infrastructure only
|
||||||
|
cd apps/backend && go run ./cmd/server # API on :8080
|
||||||
|
npm run dev:auth # auth on :3001
|
||||||
|
npm run dev:frontend # dashboard (Vite)
|
||||||
|
```
|
||||||
|
|
||||||
|
Point the frontend at the stack via `apps/frontend/.env.local`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=http://localhost/api/v1
|
||||||
|
VITE_AUTH_BASE_URL=http://localhost/auth
|
||||||
|
```
|
||||||
|
|
||||||
|
Or skip the backend entirely: `?demo=true` runs a fully client-side workspace.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -29,6 +44,23 @@ services yourself.
|
|||||||
If you changed `apps/backend/openapi/openapi.yaml`, run
|
If you changed `apps/backend/openapi/openapi.yaml`, run
|
||||||
`npm run generate:client`. Commit the regenerated output with your change.
|
`npm run generate:client`. Commit the regenerated output with your change.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/
|
||||||
|
backend/ Go + Gin API, sqlc, migrations, object storage
|
||||||
|
auth/ Better Auth on Hono (sessions, JWT, OAuth, mail)
|
||||||
|
frontend/ SolidJS + Tailwind dashboard
|
||||||
|
cli/ `primora` CLI - login, context, projects, buckets, objects, keys, audit
|
||||||
|
mcp/ `@primora/mcp` - MCP stdio server exposing `primora_*` tools
|
||||||
|
desktop/ Tauri 2 shell - connects to a deployment URL, tray presence
|
||||||
|
packages/
|
||||||
|
api-client/ OpenAPI-generated TypeScript client (do not hand-edit)
|
||||||
|
shared-types/
|
||||||
|
infra/nginx/ Reverse proxy config
|
||||||
|
scripts/ setup.sh, verify-production-ready.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Generated code is never hand-edited** — `packages/api-client/src/generated`
|
- **Generated code is never hand-edited** — `packages/api-client/src/generated`
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# 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 project secrets (Vault page, `secret://` job refs), 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 (local driver) | yes |
|
||||||
|
| `BACKEND_STORAGE_DRIVER` | `local` (default) or `s3` — S3-compatible object storage | no |
|
||||||
|
| `S3_ENDPOINT` / `S3_REGION` / `S3_BUCKET` | S3 API endpoint (empty endpoint → AWS `s3.<region>.amazonaws.com`), region, bucket | with `s3` |
|
||||||
|
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 credentials | with `s3` |
|
||||||
|
| `S3_PREFIX` / `S3_PATH_STYLE` | optional key prefix; path-style URLs (default `true`, needed by MinIO/Garage — set `false` for AWS virtual-hosted) | no |
|
||||||
|
| `S3_PUBLIC_ENDPOINT` | client-facing S3 endpoint used for presigned URLs when `S3_ENDPOINT` is internal-only (e.g. `http://minio:9000`); signatures bind the public host | no |
|
||||||
|
| `FUNCTIONS_DRIVER` | Function execution driver: `exec` (default — runtime binary on the backend host; code runs with backend privileges) or `docker` — each invoke runs in an ephemeral locked-down container (no network, read-only fs, 128 MiB, non-root). Docker mode needs `/var/run/docker.sock` mounted into the backend (see the commented volume in `docker-compose.yml`) and the runtime images pulled (`oven/bun`, `denoland/deno`). Source code is capped at 256 KiB under the docker driver | no |
|
||||||
|
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` / `FUNCTIONS_MAX_OUTPUT_BYTES` | Functions: runtime binary (`auto` resolves `bun`/`deno` — exec driver only; docker driver uses the matching official image), per-run timeout (default 30 s), captured-output cap (default 64 KiB) | no |
|
||||||
|
| `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 defaults to the **local filesystem** (`backend_storage`
|
||||||
|
volume) — durable, backed-up disk on a single host. For multi-node or
|
||||||
|
external object stores set `BACKEND_STORAGE_DRIVER=s3` + the `S3_*`
|
||||||
|
variables above (AWS, MinIO, Garage, R2 all work).
|
||||||
|
|
||||||
|
## Functions trust model
|
||||||
|
|
||||||
|
Two drivers, two trust levels:
|
||||||
|
|
||||||
|
- **`exec` (default)** — function code runs as a child process of the
|
||||||
|
backend: same host, same privileges, no sandbox. Treat every project
|
||||||
|
member who can edit a function as holding a shell on the backend host.
|
||||||
|
Appropriate for single-team self-hosted deployments.
|
||||||
|
- **`docker`** — each invoke runs in an ephemeral container: no network,
|
||||||
|
read-only rootfs, 128 MiB memory, half a CPU, 64 pids, non-root, all
|
||||||
|
capabilities dropped. Requires the Docker socket mounted into the
|
||||||
|
backend (commented volume in `docker-compose.yml`) — note the socket is
|
||||||
|
itself root-equivalent on the host, so this isolates *function code*,
|
||||||
|
not operators. Source code capped at 256 KiB. For hostile multi-tenant
|
||||||
|
workloads, a dedicated microVM runtime (Firecracker/gVisor) remains the
|
||||||
|
roadmap item.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
+29
-2
@@ -61,10 +61,37 @@ The important ones:
|
|||||||
| `POSTGRES_*` / `DATABASE_URL` | Postgres connection |
|
| `POSTGRES_*` / `DATABASE_URL` | Postgres connection |
|
||||||
| `DRAGONFLY_URL` | Cache/rate-limit store |
|
| `DRAGONFLY_URL` | Cache/rate-limit store |
|
||||||
| `SMTP_*` / `MAIL_FROM` | Transactional email (Mailpit in dev, or Resend via `RESEND_API_KEY`) |
|
| `SMTP_*` / `MAIL_FROM` | Transactional email (Mailpit in dev, or Resend via `RESEND_API_KEY`) |
|
||||||
| `GITHUB_CLIENT_ID` / `GOOGLE_CLIENT_ID` / `DISCORD_*` / `MICROSOFT_*` | Optional OAuth providers |
|
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars, `openssl rand -hex 32`) — encrypts the project vault, integration credentials and webhook secrets at rest. Without it the Vault page and `secret://` job refs are unavailable |
|
||||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling, `0` disables |
|
| `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app |
|
||||||
|
| `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only |
|
||||||
|
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app |
|
||||||
|
| `BACKEND_STORAGE_DRIVER` | `local` (default, `BACKEND_STORAGE_ROOT`) or `s3` — S3-compatible object storage |
|
||||||
|
| `S3_ENDPOINT` / `S3_BUCKET` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 backend config (with `S3_REGION`, `S3_PREFIX`, `S3_PATH_STYLE`) |
|
||||||
|
| `S3_PUBLIC_ENDPOINT` | Client-facing S3 host for presigned URLs when `S3_ENDPOINT` is internal-only |
|
||||||
|
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` | JS runtime for Functions — `auto` (default, resolves `bun`/`deno` from PATH) or a binary path; 30 s default timeout |
|
||||||
| `VITE_DEMO_MODE` | Enable the client-side demo workspace |
|
| `VITE_DEMO_MODE` | Enable the client-side demo workspace |
|
||||||
|
|
||||||
|
### Instance administration
|
||||||
|
|
||||||
|
The **first public sign-up becomes the instance admin** — on a fresh database the
|
||||||
|
login page only offers sign-up. After that, public sign-up is closed and the
|
||||||
|
admin can reopen it in-app.
|
||||||
|
|
||||||
|
Admins get a **Settings → Instance** tab that manages the instance without a
|
||||||
|
restart: public sign-up, mail transport (Resend or SMTP), and rate limits.
|
||||||
|
Every setting resolves in the order **in-app value → `.env` → built-in
|
||||||
|
default**; resetting a key reverts to env/default. Secrets (SMTP password,
|
||||||
|
Resend key) are stored AES-256-GCM-encrypted and never displayed back.
|
||||||
|
|
||||||
|
API equivalents:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/v1/instance/public # unauthenticated — login page state
|
||||||
|
GET /api/v1/instance/settings # platform admin
|
||||||
|
PUT /api/v1/instance/settings/{key} # platform admin — {"value": ...}
|
||||||
|
DELETE /api/v1/instance/settings/{key} # platform admin — revert to env/default
|
||||||
|
```
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,65 @@
|
|||||||
# Primora
|
<p align="center">
|
||||||
|
<img src="./primora.svg" alt="Primora" width="120">
|
||||||
|
</p>
|
||||||
|
|
||||||
A self-hosted backend platform for building products — auth, Postgres-backed APIs, object storage, JSON document collections, API keys, and a full audit log behind one dashboard. Inspired by the UX of Appwrite and Supabase, with no hosted tier: your data stays on your hardware.
|
<h1 align="center">Primora</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Self-hosted backend platform for building products.<br>
|
||||||
|
Auth, Postgres-backed APIs, object storage, and audit logs on your own hardware.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="#quick-start">Quick Start</a> ·
|
||||||
|
<a href="QUICK_START.md">Documentation</a> ·
|
||||||
|
<a href="https://github.com/Dvorinka/Primora/releases">Releases</a> ·
|
||||||
|
<a href="CONTRIBUTING.md">Contributing</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/Dvorinka/Primora/actions/workflows/ci.yml"><img src="https://github.com/Dvorinka/Primora/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||||
|
<a href="https://github.com/Dvorinka/Primora/releases"><img src="https://img.shields.io/github/v/release/Dvorinka/Primora" alt="Release"></a>
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/github/license/Dvorinka/Primora" alt="License"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## What is Primora?
|
||||||
|
|
||||||
|
Primora is a self-hosted backend platform: organizations, projects, auth,
|
||||||
|
S3-style object storage, JSON document collections, scoped API keys, and a
|
||||||
|
full audit log behind one dashboard. Inspired by the UX of Appwrite and
|
||||||
|
Supabase, with no hosted tier - your data stays on your hardware.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| Overview | Storage | Telemetry |
|
||||||
|
|:-:|:-:|:-:|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>More screenshots</summary>
|
||||||
|
|
||||||
|
| Collections | Automation | Audit log |
|
||||||
|
|:-:|:-:|:-:|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Organizations & projects** - workspaces with scoped org and project roles.
|
||||||
|
- **Auth** - email/password plus optional GitHub, Google, Discord, and Microsoft OAuth. JWTs verified by the API against JWKS.
|
||||||
|
- **Storage** - S3-style buckets on local disk or any S3-compatible backend (AWS, MinIO, Garage, R2), public or private. With `s3` the API mints presigned upload/download URLs so large objects skip the backend.
|
||||||
|
- **Collections** - schema-flexible JSON documents in Postgres JSONB, with filter/order queries.
|
||||||
|
- **Functions** - project-scoped JS/TS run by bun/deno — on the host (`exec`) or in ephemeral locked-down containers (`docker` driver) — with per-run logs. Trigger them four ways: manual invoke, cron schedules (`function_id` on a job), inbound hooks (`mode: "function"`), or domain events (`event_pattern` like `document.*`).
|
||||||
|
- **Vault** - AES-256-GCM project secrets plus an Argon2id/XChaCha20 local vault in the CLI and desktop app.
|
||||||
|
- **Automation** - scheduled jobs, inbound webhooks, telemetry alert rules, and `secret://` refs resolved at delivery.
|
||||||
|
- **Realtime** - SSE event stream with a typed client SDK (`@primora/client`) — channel subscriptions, presence counts, and client-published `custom.*` events that fan out to webhooks and functions.
|
||||||
|
- **API keys** - `prm_<prefix>_<secret>` credentials, shown once.
|
||||||
|
- **Agent setup** - every project can mint a `write`-scoped key and generate a copyable AI-agent prompt that wires telemetry, database tracking, and MCP/CLI access into any repository.
|
||||||
|
- **Audit log** - every mutating request recorded, with CSV/JSON export.
|
||||||
|
- **Demo mode** - fully client-side seeded workspace via `?demo=true`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite)
|
Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite)
|
||||||
@@ -10,116 +69,53 @@ Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite)
|
|||||||
──▶ Local filesystem object storage
|
──▶ Local filesystem object storage
|
||||||
```
|
```
|
||||||
|
|
||||||
## What you get
|
## Quick Start
|
||||||
|
|
||||||
- **Organizations & projects** — top-level workspaces containing projects, members, and scoped roles (`owner`/`admin`/`member` org roles, `admin`/`developer`/`viewer` project roles).
|
|
||||||
- **Auth** — email/password plus optional GitHub, Google, Discord, and Microsoft OAuth via Better Auth. JWTs minted by the auth service are verified by the Go API against JWKS.
|
|
||||||
- **Storage** — S3-style buckets and objects backed by your local filesystem, with public/private visibility and downloadable URLs.
|
|
||||||
- **Collections** — schema-flexible JSON documents stored in Postgres JSONB.
|
|
||||||
- **API keys** — `prm_<prefix>_<secret>` credentials; secrets are shown once.
|
|
||||||
- **Audit log** — every mutating request recorded with actor, resource, request ID, and timestamp; CSV/JSON export from the dashboard.
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Quick start (Docker)
|
|
||||||
|
|
||||||
Prerequisites: Docker with the Compose plugin.
|
Prerequisites: Docker with the Compose plugin.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Installs into `./primora`, asks one question — the URL you'll open the app on
|
||||||
|
(domain, IP, optional port) — generates secrets, and starts the stack.
|
||||||
|
Non-interactive: `PUBLIC_URL=https://primora.example.com bash install.sh`.
|
||||||
|
Or 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
|
||||||
```
|
```
|
||||||
|
|
||||||
or manually:
|
Then open `http://localhost` - or append `?demo=true` to explore a seeded
|
||||||
|
workspace without signing up. For prebuilt images, native development, port
|
||||||
|
conflicts, and troubleshooting see [QUICK_START.md](QUICK_START.md).
|
||||||
|
|
||||||
```bash
|
## Configuration
|
||||||
cp .env.example .env # fill JWT_SECRET and BETTER_AUTH_SECRET
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open `http://localhost`. Mailpit is at `http://localhost/mailpit/` for local email.
|
All configuration lives in `.env` - see `.env.example` for the annotated list
|
||||||
|
and [QUICK_START.md](QUICK_START.md#configuration) for the important ones.
|
||||||
|
|
||||||
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.
|
## Ecosystem
|
||||||
|
|
||||||
## Verification
|
- **[CLI](apps/cli)** - `primora` for orgs, projects, buckets, objects, keys, jobs, and audit — plus an encrypted local secrets vault (`vault:*`, `secrets:*`, `inject`), the shared project vault (`secrets:* --remote`, `secret://NAME` refs in job payloads), an agent credential broker (`agent`), and compose-stack operator commands (`stack:*`).
|
||||||
|
- **[MCP server](apps/mcp)** - exposes `primora_*` tools to MCP clients.
|
||||||
| Check | Endpoint |
|
- **[Desktop](apps/desktop)** - Tauri 2 shell connecting to your deployment, with tray presence and a local Secrets Vault window (drives the CLI's vault).
|
||||||
|---|---|
|
- **PWA** - the dashboard is installable on mobile and desktop browsers.
|
||||||
| Frontend | `http://localhost/` |
|
|
||||||
| Backend liveness | `http://localhost/api/v1/health/liveness` |
|
|
||||||
| Backend readiness | `http://localhost/api/v1/health/readiness` |
|
|
||||||
| Auth health | `http://localhost/auth-health` |
|
|
||||||
| OpenAPI spec | `http://localhost/api/v1/openapi.yaml` |
|
|
||||||
|
|
||||||
## Local development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d postgres dragonfly mailpit # infrastructure only
|
|
||||||
cd apps/backend && go run ./cmd/server # API on :8080
|
|
||||||
npm run dev:auth # auth on :3001
|
|
||||||
npm run dev:frontend # dashboard (Vite)
|
|
||||||
```
|
|
||||||
|
|
||||||
Point the frontend at the stack by adding to `apps/frontend/.env.local`:
|
|
||||||
|
|
||||||
```env
|
|
||||||
VITE_API_BASE_URL=http://localhost/api/v1
|
|
||||||
VITE_AUTH_BASE_URL=http://localhost/auth
|
|
||||||
```
|
|
||||||
|
|
||||||
Or skip all of it: `http://localhost:<vite-port>/?demo=true` runs entirely in the browser.
|
|
||||||
|
|
||||||
## CLI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build --workspace @primora/cli # → apps/cli/dist/cli.js
|
|
||||||
node apps/cli/dist/cli.js login # or: npx primora login once published
|
|
||||||
```
|
|
||||||
|
|
||||||
Supports session sign-in (`primora login`) and API-key mode (`primora login --api-key prm_…` or `PRIMORA_API_KEY`). Context lives in `~/.config/primora/config.json`; `primora use` picks org + project interactively. Commands: `orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`, `audit list --follow`. Every command accepts `--json`.
|
|
||||||
|
|
||||||
## Desktop & phone
|
|
||||||
|
|
||||||
- **Desktop** — `apps/desktop` is a Tauri 2 shell (Linux/Windows/macOS). First
|
|
||||||
run asks for your deployment URL, then renders the same dashboard in a
|
|
||||||
webview with a system-tray presence. Nothing is bundled; your server does the
|
|
||||||
work. See [apps/desktop/README.md](apps/desktop/README.md).
|
|
||||||
- **Phone / PWA** — the dashboard ships a web manifest and service worker.
|
|
||||||
Open your deployment in a mobile browser and "Add to Home Screen"; the shell
|
|
||||||
is precached, API traffic always goes to the network. Chromium desktop
|
|
||||||
browsers get an in-app install banner.
|
|
||||||
|
|
||||||
## Quality gate
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run check # backend tests + frontend typecheck + build + generated-code drift check
|
|
||||||
npm test # workspace tests
|
|
||||||
npm run generate:sqlc # regenerate sqlc after changing queries/schema
|
|
||||||
npm run generate:client # regenerate the TS client after changing openapi.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Repository layout
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/
|
|
||||||
backend/ Go + Gin API, sqlc, migrations, object storage
|
|
||||||
auth/ Better Auth on Hono (sessions, JWT, OAuth, mail)
|
|
||||||
frontend/ SolidJS + Tailwind dashboard
|
|
||||||
cli/ `primora` CLI — login, context, projects, buckets, objects, keys, audit
|
|
||||||
mcp/ `@primora/mcp` — MCP stdio server exposing `primora_*` tools
|
|
||||||
desktop/ Tauri 2 shell — connects to a deployment URL, tray presence
|
|
||||||
packages/
|
|
||||||
api-client/ OpenAPI-generated TypeScript client (do not hand-edit)
|
|
||||||
shared-types/
|
|
||||||
infra/nginx/ Reverse proxy config
|
|
||||||
scripts/ setup.sh, verify-production-ready.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [QUICK_START.md](QUICK_START.md) — setup, configuration, troubleshooting
|
- [QUICK_START.md](QUICK_START.md) - setup, configuration, troubleshooting
|
||||||
- [project_backend.md](project_backend.md) / [project_frontend.md](project_frontend.md) — original design specs
|
- [project_backend.md](project_backend.md) / [project_frontend.md](project_frontend.md) - original design specs
|
||||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — development workflow
|
- `apps/backend/openapi/openapi.yaml` - API contract
|
||||||
- `apps/backend/openapi/openapi.yaml` — API contract
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+312
-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,319 @@ 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~~ ✅ — `tauri-plugin-updater`
|
||||||
|
wired: signed updater artifacts (`createUpdaterArtifacts`), minisign
|
||||||
|
keypair, `latest.json` manifest assembled on release, in-app
|
||||||
|
"Update & restart" on the connect screen. macOS/Windows OS-level code
|
||||||
|
signing (notarization/Authenticode) still open — needs paid certs.
|
||||||
|
|
||||||
|
## 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**~~ ✅ — `BACKEND_STORAGE_DRIVER=s3` shipped
|
||||||
|
(Phase 10): any S3-compatible API + presigned URLs; local disk remains
|
||||||
|
the default.
|
||||||
|
|
||||||
|
### 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; updater + minisign signing shipped (OS-level signing
|
||||||
|
remains open).
|
||||||
|
- ~~Optional: SBOM~~ ✅ — CycloneDX SBOMs per published image + source
|
||||||
|
tree, attached to every release (`anchore/sbom-action`).
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## Secrets vault (unreleased)
|
||||||
|
|
||||||
|
Arca-inspired secrets management, clean-room — every surface covered.
|
||||||
|
|
||||||
|
- **Local vault** (`apps/cli`) — `PRMVLT01` format: Argon2id (64 MiB/3/4) →
|
||||||
|
XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes. `vault:*`,
|
||||||
|
`secrets:*`, `inject` (env injection + `primora://` refs), TTL sessions,
|
||||||
|
and the `agent` credential broker (dummy tokens in the child, real secret
|
||||||
|
attached on the wire per grant).
|
||||||
|
- **Project vault** (`core.project_secrets`, migration 00011) — AES-256-GCM
|
||||||
|
via the platform encryptor; metadata-only list, audit-logged reveal;
|
||||||
|
`secret://NAME` refs in job payloads resolve at delivery and fail loudly
|
||||||
|
when unresolved.
|
||||||
|
- **Surfaces** — dashboard Vault page (+ local-vault card under the desktop
|
||||||
|
shell), desktop vault window via Tauri IPC → `primora` binary,
|
||||||
|
`secrets:* --remote` CLI mode, and MCP tools that are metadata/write-only
|
||||||
|
so agents never hold plaintext.
|
||||||
|
|
||||||
|
## Phase 11 — Document queries (unreleased)
|
||||||
|
|
||||||
|
PostgREST-style filtering and ordering on collection documents.
|
||||||
|
|
||||||
|
- `GET /collections/:id/documents?filter=…&order=…` — `filter` is a
|
||||||
|
comma-separated `field.op.value` list (ops `eq neq gt gte lt lte like in
|
||||||
|
is`), `order` a `field.asc|field.desc` list. Dotted paths index into
|
||||||
|
document data (`meta.city.eq.Prague`); `id`/`created_at`/`updated_at` are
|
||||||
|
filterable columns.
|
||||||
|
- Whitelisted field/operator parsing, parameterized values, JSONB
|
||||||
|
extraction — no interpolated SQL. Plain lists keep the sqlc path.
|
||||||
|
- `primora documents list --collection <slug> --filter … --order …` in the
|
||||||
|
CLI; `filter`/`order` params in the generated client.
|
||||||
|
|
||||||
|
## Phase 8 — Realtime client SDK (unreleased)
|
||||||
|
|
||||||
|
Channel subscriptions over the Phase 7 SSE stream, in `@primora/client`.
|
||||||
|
|
||||||
|
- `createRealtimeClient({ endpoint, key, projectId })` — fetch +
|
||||||
|
ReadableStream reader (EventSource can't send `X-API-Key`), auto-reconnect
|
||||||
|
with capped backoff, `onStateChange` lifecycle.
|
||||||
|
- `channel("documents").on("created", cb)` — namespaces `document`, `object`,
|
||||||
|
`issue`, `deploy`, `"*"` for everything; trailing `s` optional.
|
||||||
|
- **Presence** ✅ — server tracks realtime subscribers per project;
|
||||||
|
`presence.update` {online: n} broadcasts on every join/leave (realtime-only,
|
||||||
|
never fans out to webhooks/functions), `GET …/realtime/presence` reads the
|
||||||
|
count, `client.presence()` wraps it.
|
||||||
|
- **Client events** ✅ — `POST /projects/:id/events` publishes `custom.*`
|
||||||
|
types through the standard fan-out (realtime + webhooks + functions);
|
||||||
|
`client.publish("custom.x", data)` and `primora events:send` both reach it.
|
||||||
|
The `custom.*` prefix is enforced so clients can't spoof system events.
|
||||||
|
|
||||||
|
## Notifications — alert rules (unreleased)
|
||||||
|
|
||||||
|
Telemetry-driven alerts delivered through the existing webhook dispatcher.
|
||||||
|
|
||||||
|
- `core.alert_rules` (migration 00012) — `heartbeat_silence` and
|
||||||
|
`error_spike` kinds, per-component or project-wide scope, evaluated by a
|
||||||
|
60 s backend ticker.
|
||||||
|
- Transition-based firing: `alert.fired` once on breach, `alert.resolved`
|
||||||
|
on recovery — no notification storms. Both are webhook event types and
|
||||||
|
realtime events, so any existing subscriber receives them signed.
|
||||||
|
- CRUD via `/projects/:id/alerts`; Integrations page manages rules next to
|
||||||
|
webhooks and shows live `firing` state.
|
||||||
|
|
||||||
|
## Inbound webhooks (unreleased)
|
||||||
|
|
||||||
|
Project-scoped public ingest for external systems.
|
||||||
|
|
||||||
|
- `POST /api/v1/hooks/<token>` — token is the credential; optional
|
||||||
|
`X-Primora-Signature` HMAC-SHA256 verification when a secret is set.
|
||||||
|
- `event` mode republishes the body as `inbound.received` (webhooks +
|
||||||
|
realtime fan out for free); `job` mode enqueues a scheduled-job run with
|
||||||
|
the received body as payload (`triggered_by: "hook"`).
|
||||||
|
- 256 KiB body cap; `last_received_at` tracked; managed from Integrations.
|
||||||
|
|
||||||
|
## HA scheduler (unreleased)
|
||||||
|
|
||||||
|
Multi-replica safety for the job scheduler and alert evaluator.
|
||||||
|
|
||||||
|
- PostgreSQL advisory locks (session-scoped, separate keys per component)
|
||||||
|
on a dedicated pinned pool connection; only the leader scans for due work.
|
||||||
|
- Manual runs and hook-triggered runs work on any replica — the lock gates
|
||||||
|
only the scheduled scan, so leadership loss never blocks on-demand work.
|
||||||
|
- Released on shutdown; failover verified with a live two-replica test —
|
||||||
|
the survivor acquired leadership and fired the due job.
|
||||||
|
|
||||||
|
## Email surface (unreleased)
|
||||||
|
|
||||||
|
Reusable templates + an operational send log.
|
||||||
|
|
||||||
|
- `core.email_log` (migration 00014) — every send recorded: template,
|
||||||
|
recipient, subject, `sent`/`failed` status, error text.
|
||||||
|
- `Mailer` stays transport-only (Resend when configured, SMTP otherwise);
|
||||||
|
templates render subject + plaintext body. First template: `invitation`.
|
||||||
|
- `GET /projects/:id/emails` lists recent sends; Members page shows an
|
||||||
|
"Email log" card under pending invitations.
|
||||||
|
|
||||||
|
## Future phases — candidates
|
||||||
|
|
||||||
|
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
||||||
|
|
||||||
|
- **Phase 9 — Functions** — ✅ shipped: `core.functions` +
|
||||||
|
`core.function_runs`, bun/deno exec runner (stdin payload, env injection,
|
||||||
|
capped output, timeout), invoke API + dashboard page. Triggers shipped:
|
||||||
|
scheduled jobs (`function_id` on a job), inbound hooks (`mode:
|
||||||
|
"function"`), and domain events (`event_pattern`, e.g. `document.*`).
|
||||||
|
Runs record trigger source (`manual`/`schedule`/`hook`/`event`).
|
||||||
|
Isolation shipped: `FUNCTIONS_DRIVER=docker` runs every invoke in an
|
||||||
|
ephemeral locked-down container (no network, read-only fs, non-root).
|
||||||
|
Remaining candidate: Firecracker/gVisor microVMs for hostile
|
||||||
|
multi-tenant workloads — docker socket trust is documented.
|
||||||
|
- **Phase 10 — Storage backends** — ✅ shipped: `BACKEND_STORAGE_DRIVER=s3`
|
||||||
|
+ `S3_*` env vars, stdlib SigV4, verified against a real S3 server.
|
||||||
|
Presigned upload/download URLs shipped: `POST
|
||||||
|
/buckets/:id/object-presigns` mints SigV4 query-signed GET/PUT URLs
|
||||||
|
(`S3_PUBLIC_ENDPOINT` rewrites the client-facing host); local driver
|
||||||
|
returns a clear error — a presigned URL needs a real object API behind it.
|
||||||
|
- **HA mode** — ✅ shipped: PostgreSQL advisory-lock leadership for the
|
||||||
|
scheduler and alert evaluator; failover verified with two replicas.
|
||||||
|
- **Email surface** — ✅ shipped: templated sends + `core.email_log` +
|
||||||
|
dashboard card; more templates land as new flows need them.
|
||||||
|
|
||||||
|
## Release hardening (unreleased)
|
||||||
|
|
||||||
|
- **Desktop auto-updater** — `tauri-plugin-updater`: signed updater
|
||||||
|
artifacts, `latest.json` manifest assembled per release, in-app
|
||||||
|
"Update & restart" on the connect screen. Unsigned artifacts can never
|
||||||
|
install — no signature means no manifest.
|
||||||
|
- **SBOMs** — CycloneDX per GHCR image + source tree, attached to every
|
||||||
|
GitHub Release.
|
||||||
|
- Requires `TAURI_SIGNING_PRIVATE_KEY` repo secret (keypair generated
|
||||||
|
locally; private half never committed).
|
||||||
|
|
||||||
|
## Sandboxed functions (unreleased)
|
||||||
|
|
||||||
|
- `FUNCTIONS_DRIVER=docker` — every invoke runs in an ephemeral sibling
|
||||||
|
container: `--network none`, read-only rootfs, 128 MiB / 0.5 CPU / 64
|
||||||
|
pids, non-root, `--cap-drop ALL`. Source travels as base64 env — no
|
||||||
|
bind mount, so Docker Desktop and remote daemons work. Timeout
|
||||||
|
force-removes the named container.
|
||||||
|
- `exec` remains the default for single-team deployments; the trust-model
|
||||||
|
doc describes both levels.
|
||||||
|
|
||||||
|
## Client events + presence (unreleased)
|
||||||
|
|
||||||
|
- `POST /projects/:id/events` — members/API keys publish `custom.*`
|
||||||
|
events through the standard fan-out (realtime + webhooks + functions);
|
||||||
|
system types can't be spoofed.
|
||||||
|
- Presence — per-project realtime subscriber counts; `presence.update`
|
||||||
|
broadcasts on join/leave (stream-only), `GET …/realtime/presence`
|
||||||
|
reads the count. SDK: `publish()` + `presence()`; CLI: `events:send`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
|
||||||
@@ -5,7 +5,8 @@ import { requestId } from "hono/request-id";
|
|||||||
import { HTTPException } from "hono/http-exception";
|
import { HTTPException } from "hono/http-exception";
|
||||||
import { createClient } from "redis";
|
import { createClient } from "redis";
|
||||||
|
|
||||||
import { auth, authPool, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js";
|
import { auth, promoteAdminEmails, runAuthMigrations, verifyUsersWithoutMailTransport } from "./lib/auth.js";
|
||||||
|
import { authPool } from "./lib/db.js";
|
||||||
import { env } from "./lib/env.js";
|
import { env } from "./lib/env.js";
|
||||||
|
|
||||||
const redisClient = createClient({
|
const redisClient = createClient({
|
||||||
@@ -19,6 +20,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await retry("auth_migrations", runAuthMigrations);
|
await retry("auth_migrations", runAuthMigrations);
|
||||||
|
await verifyUsersWithoutMailTransport();
|
||||||
await promoteAdminEmails();
|
await promoteAdminEmails();
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|||||||
+125
-48
@@ -1,56 +1,74 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
|
import { APIError } from "better-auth/api";
|
||||||
import { admin, jwt } from "better-auth/plugins";
|
import { admin, jwt } from "better-auth/plugins";
|
||||||
import { getMigrations } from "better-auth/db/migration";
|
import { getMigrations } from "better-auth/db/migration";
|
||||||
import { Pool } from "pg";
|
|
||||||
|
|
||||||
import { sendTransactionalEmail } from "./mail.js";
|
import { authPool } from "./db.js";
|
||||||
|
import { hasMailTransport, sendTransactionalEmail } from "./mail.js";
|
||||||
|
import { getBoolSetting } from "./settings.js";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
|
|
||||||
export const authPool = new Pool({
|
// OAuth providers exist only on the managed tier (PRIMORA_MANAGED=true).
|
||||||
connectionString: env.DATABASE_URL,
|
// Self-hosted instances are email/password only — the env keys are ignored
|
||||||
});
|
// unless the managed flag is set.
|
||||||
|
const socialProviders = env.PRIMORA_MANAGED
|
||||||
const socialProviders = {
|
? {
|
||||||
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
||||||
? {
|
? {
|
||||||
github: {
|
github: {
|
||||||
clientId: env.GITHUB_CLIENT_ID,
|
clientId: env.GITHUB_CLIENT_ID,
|
||||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
||||||
? {
|
? {
|
||||||
google: {
|
google: {
|
||||||
clientId: env.GOOGLE_CLIENT_ID,
|
clientId: env.GOOGLE_CLIENT_ID,
|
||||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
|
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
|
||||||
? {
|
? {
|
||||||
discord: {
|
discord: {
|
||||||
clientId: env.DISCORD_CLIENT_ID,
|
clientId: env.DISCORD_CLIENT_ID,
|
||||||
clientSecret: env.DISCORD_CLIENT_SECRET,
|
clientSecret: env.DISCORD_CLIENT_SECRET,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
|
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
|
||||||
? {
|
? {
|
||||||
microsoft: {
|
microsoft: {
|
||||||
clientId: env.MICROSOFT_CLIENT_ID,
|
clientId: env.MICROSOFT_CLIENT_ID,
|
||||||
clientSecret: env.MICROSOFT_CLIENT_SECRET,
|
clientSecret: env.MICROSOFT_CLIENT_SECRET,
|
||||||
tenantId: env.MICROSOFT_TENANT_ID,
|
tenantId: env.MICROSOFT_TENANT_ID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
appName: "Primora",
|
appName: "Primora",
|
||||||
secret: env.BETTER_AUTH_SECRET,
|
secret: env.BETTER_AUTH_SECRET,
|
||||||
baseURL: env.BETTER_AUTH_URL,
|
baseURL: env.BETTER_AUTH_URL,
|
||||||
trustedOrigins: [env.VITE_APP_URL, env.AUTH_BASE_URL],
|
// Self-host: the browser reaches /auth through the same nginx origin as the
|
||||||
|
// app, so whatever host actually served this request is trusted — static
|
||||||
|
// localhost defaults alone 403 every sign-up on LAN IPs or custom ports.
|
||||||
|
// An attacker's cross-site Origin still won't match the server host.
|
||||||
|
trustedOrigins: (request) => {
|
||||||
|
const origins = [env.VITE_APP_URL, env.AUTH_BASE_URL];
|
||||||
|
const host = request?.headers.get("x-forwarded-host") ?? request?.headers.get("host");
|
||||||
|
if (host) {
|
||||||
|
const proto = request?.headers.get("x-forwarded-proto") ?? "http";
|
||||||
|
origins.push(`${proto}://${host}`);
|
||||||
|
// TLS often terminates at an external proxy — nginx then reports http.
|
||||||
|
// Both schemes on the serving host are legitimately this instance.
|
||||||
|
origins.push(proto === "https" ? `http://${host}` : `https://${host}`);
|
||||||
|
}
|
||||||
|
return origins;
|
||||||
|
},
|
||||||
database: authPool,
|
database: authPool,
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -63,18 +81,66 @@ export const auth = betterAuth({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
// Gates only the public sign-up endpoint. Users created through the
|
||||||
|
// admin plugin or OAuth callbacks take different paths and pass.
|
||||||
|
async before(user, ctx) {
|
||||||
|
const data = { ...user };
|
||||||
|
// Without a mail transport the verification mail can never arrive —
|
||||||
|
// mark every created account verified so nothing downstream nags
|
||||||
|
// "unverified" on users nobody can verify.
|
||||||
|
if (!(await hasMailTransport())) {
|
||||||
|
data.emailVerified = true;
|
||||||
|
}
|
||||||
|
if (ctx?.path === "/sign-up/email") {
|
||||||
|
const { rows } = await authPool.query<{ n: number }>(
|
||||||
|
`select count(*)::int as n from "user"`,
|
||||||
|
);
|
||||||
|
if (rows[0].n === 0) {
|
||||||
|
// Bootstrap: the first public signup becomes the instance admin.
|
||||||
|
return { data: { ...data, role: "admin" } };
|
||||||
|
}
|
||||||
|
const enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED);
|
||||||
|
if (!enabled) {
|
||||||
|
throw new APIError("FORBIDDEN", {
|
||||||
|
message: "Sign-up is disabled on this instance.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data.emailVerified === user.emailVerified ? undefined : { data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
emailVerification: {
|
emailVerification: {
|
||||||
sendOnSignUp: true,
|
sendOnSignUp: true,
|
||||||
autoSignInAfterVerification: true,
|
autoSignInAfterVerification: true,
|
||||||
async sendVerificationEmail({ user, url }) {
|
async sendVerificationEmail({ user, url }) {
|
||||||
await sendTransactionalEmail({
|
// No transport at all → the mail can never arrive; the user was already
|
||||||
to: user.email,
|
// marked verified at creation.
|
||||||
subject: "Verify your Primora email",
|
if (!(await hasMailTransport())) return;
|
||||||
text: `Verify your Primora email: ${url}`,
|
// Verification is optional — a misconfigured transport must not fail
|
||||||
html: `<p>Verify your Primora email.</p><p><a href="${url}">Verify email</a></p>`,
|
// sign-up or sign-in.
|
||||||
});
|
try {
|
||||||
|
await sendTransactionalEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Verify your Primora email",
|
||||||
|
text: `Verify your Primora email: ${url}`,
|
||||||
|
html: `<p>Verify your Primora email.</p><p><a href="${url}">Verify email</a></p>`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(JSON.stringify({ level: "warn", msg: "verification_email_failed", error }));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 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({
|
||||||
@@ -104,6 +170,17 @@ export async function runAuthMigrations() {
|
|||||||
await migrations.runMigrations();
|
await migrations.runMigrations();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** No transport → existing unverified users can never resolve that state.
|
||||||
|
* Best-effort: a startup failure here must never take the service down. */
|
||||||
|
export async function verifyUsersWithoutMailTransport() {
|
||||||
|
try {
|
||||||
|
if (await hasMailTransport()) return;
|
||||||
|
await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(JSON.stringify({ level: "warn", msg: "verify_without_transport_failed", error }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Bootstrap admins by email — run after migrations so the role column exists. */
|
/** Bootstrap admins by email — run after migrations so the role column exists. */
|
||||||
export async function promoteAdminEmails() {
|
export async function promoteAdminEmails() {
|
||||||
const emails = (env.AUTH_ADMIN_EMAILS ?? "")
|
const emails = (env.AUTH_ADMIN_EMAILS ?? "")
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
import { env } from "./env.js";
|
||||||
|
|
||||||
|
// Shared by better-auth and the instance-settings reader. Lives in its own
|
||||||
|
// module so settings.ts can be imported by auth.ts without a cycle.
|
||||||
|
export const authPool = new Pool({
|
||||||
|
connectionString: env.DATABASE_URL,
|
||||||
|
});
|
||||||
@@ -18,6 +18,15 @@ const envSchema = z.object({
|
|||||||
SMTP_PORT: z.string().default("1025"),
|
SMTP_PORT: z.string().default("1025"),
|
||||||
SMTP_USER: z.string().optional(),
|
SMTP_USER: z.string().optional(),
|
||||||
SMTP_PASSWORD: z.string().optional(),
|
SMTP_PASSWORD: z.string().optional(),
|
||||||
|
// Managed tier flag — OAuth providers only activate when this is "true".
|
||||||
|
// Self-hosted instances are email/password only.
|
||||||
|
PRIMORA_MANAGED: z.string().optional(),
|
||||||
|
// Bootstrap override for public sign-up. Without it (or an in-app setting),
|
||||||
|
// sign-up is closed once the first user exists.
|
||||||
|
SIGNUP_ENABLED: z.string().optional(),
|
||||||
|
// Decrypts secret instance settings written by the backend (mail keys etc.).
|
||||||
|
PRIMORA_ENCRYPTION_KEY: z.string().optional(),
|
||||||
|
SMTP_SECURE: z.string().optional(),
|
||||||
GITHUB_CLIENT_ID: z.string().optional(),
|
GITHUB_CLIENT_ID: z.string().optional(),
|
||||||
GITHUB_CLIENT_SECRET: z.string().optional(),
|
GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||||
@@ -44,5 +53,8 @@ export const env = {
|
|||||||
JWT_TTL_SECONDS: Number(parsed.data.JWT_TTL_SECONDS),
|
JWT_TTL_SECONDS: Number(parsed.data.JWT_TTL_SECONDS),
|
||||||
SMTP_PORT: Number(parsed.data.SMTP_PORT),
|
SMTP_PORT: Number(parsed.data.SMTP_PORT),
|
||||||
AUTH_BASE_URL: parsed.data.AUTH_BASE_URL ?? parsed.data.BETTER_AUTH_URL,
|
AUTH_BASE_URL: parsed.data.AUTH_BASE_URL ?? parsed.data.BETTER_AUTH_URL,
|
||||||
|
PRIMORA_MANAGED: parsed.data.PRIMORA_MANAGED === "true",
|
||||||
|
SIGNUP_ENABLED: parsed.data.SIGNUP_ENABLED === "true",
|
||||||
|
SMTP_SECURE: parsed.data.SMTP_SECURE === "true",
|
||||||
};
|
};
|
||||||
|
|
||||||
+54
-22
@@ -2,23 +2,48 @@ import nodemailer from "nodemailer";
|
|||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
|
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
|
import { getStringSetting } from "./settings.js";
|
||||||
|
|
||||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null;
|
// Mail config resolves per send: in-app setting → env var. This keeps
|
||||||
|
// SMTP/Resend editable from the dashboard without restarting the service.
|
||||||
|
async function resolveMailConfig() {
|
||||||
|
const from = (await getStringSetting("mail.from", env.MAIL_FROM))!;
|
||||||
|
const resendApiKey = await getStringSetting("mail.resend_api_key", env.RESEND_API_KEY);
|
||||||
|
if (resendApiKey) {
|
||||||
|
return { from, resendApiKey } as const;
|
||||||
|
}
|
||||||
|
const host = await getStringSetting("mail.smtp_host", env.SMTP_HOST);
|
||||||
|
const portRaw = await getStringSetting("mail.smtp_port", String(env.SMTP_PORT));
|
||||||
|
const user = await getStringSetting("mail.smtp_user", env.SMTP_USER);
|
||||||
|
const pass = await getStringSetting("mail.smtp_password", env.SMTP_PASSWORD);
|
||||||
|
const secureRaw = await getStringSetting("mail.smtp_secure", String(env.SMTP_SECURE));
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
smtp: host
|
||||||
|
? {
|
||||||
|
host,
|
||||||
|
port: Number(portRaw) || 1025,
|
||||||
|
secure: secureRaw === "true",
|
||||||
|
auth: user ? { user, pass } : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
const transporter =
|
// True when a mail transport exists (Resend key or SMTP host). Verification
|
||||||
!resend && env.SMTP_HOST
|
// and reset mails only make sense when mail can actually leave the box.
|
||||||
? nodemailer.createTransport({
|
// Mail is optional — a failed settings read must degrade to "no transport",
|
||||||
host: env.SMTP_HOST,
|
// never break sign-up.
|
||||||
port: env.SMTP_PORT,
|
export async function hasMailTransport(): Promise<boolean> {
|
||||||
secure: false,
|
try {
|
||||||
auth: env.SMTP_USER
|
const cfg = await resolveMailConfig();
|
||||||
? {
|
if ("resendApiKey" in cfg && cfg.resendApiKey) return true;
|
||||||
user: env.SMTP_USER,
|
return "smtp" in cfg && Boolean(cfg.smtp);
|
||||||
pass: env.SMTP_PASSWORD,
|
} catch (error) {
|
||||||
}
|
console.warn(JSON.stringify({ level: "warn", msg: "mail_transport_probe_failed", error }));
|
||||||
: undefined,
|
return false;
|
||||||
})
|
}
|
||||||
: null;
|
}
|
||||||
|
|
||||||
export async function sendTransactionalEmail(input: {
|
export async function sendTransactionalEmail(input: {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -26,9 +51,12 @@ export async function sendTransactionalEmail(input: {
|
|||||||
text: string;
|
text: string;
|
||||||
html: string;
|
html: string;
|
||||||
}) {
|
}) {
|
||||||
if (resend) {
|
const cfg = await resolveMailConfig();
|
||||||
|
|
||||||
|
if ("resendApiKey" in cfg && cfg.resendApiKey) {
|
||||||
|
const resend = new Resend(cfg.resendApiKey);
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: env.MAIL_FROM,
|
from: cfg.from,
|
||||||
to: [input.to],
|
to: [input.to],
|
||||||
subject: input.subject,
|
subject: input.subject,
|
||||||
text: input.text,
|
text: input.text,
|
||||||
@@ -37,16 +65,20 @@ export async function sendTransactionalEmail(input: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!transporter) {
|
if (!("smtp" in cfg) || !cfg.smtp) {
|
||||||
throw new Error("No mail transport configured for auth service.");
|
// Self-hosted instances may run without mail — auth still works, the
|
||||||
|
// email simply never goes out.
|
||||||
|
console.warn(
|
||||||
|
JSON.stringify({ level: "warn", msg: "mail_skipped_no_transport", to: input.to, subject: input.subject }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await transporter.sendMail({
|
await nodemailer.createTransport(cfg.smtp).sendMail({
|
||||||
from: env.MAIL_FROM,
|
from: cfg.from,
|
||||||
to: input.to,
|
to: input.to,
|
||||||
subject: input.subject,
|
subject: input.subject,
|
||||||
text: input.text,
|
text: input.text,
|
||||||
html: input.html,
|
html: input.html,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
import { authPool } from "./db.js";
|
||||||
|
import { env } from "./env.js";
|
||||||
|
|
||||||
|
// Instance settings live in core.settings, written by the backend. A row
|
||||||
|
// overrides the matching env var; no row means env (or the caller's default).
|
||||||
|
// Secret values are AES-256-GCM ciphertext (base64, nonce || ct || tag)
|
||||||
|
// encrypted by the backend with PRIMORA_ENCRYPTION_KEY.
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 15_000;
|
||||||
|
const cache = new Map<string, { value: unknown; at: number }>();
|
||||||
|
|
||||||
|
export async function getSetting(key: string): Promise<unknown> {
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.value;
|
||||||
|
const { rows } = await authPool.query(
|
||||||
|
`select value, secret from core.settings where key = $1`,
|
||||||
|
[key],
|
||||||
|
);
|
||||||
|
let value: unknown;
|
||||||
|
if (rows[0]) {
|
||||||
|
const raw = rows[0].value as unknown; // JSONB arrives parsed — a string
|
||||||
|
value = rows[0].secret ? decryptSetting(String(raw)) : raw;
|
||||||
|
}
|
||||||
|
cache.set(key, { value, at: Date.now() });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBoolSetting(key: string, fallback: boolean): Promise<boolean> {
|
||||||
|
const v = await getSetting(key);
|
||||||
|
if (v === undefined) return fallback;
|
||||||
|
return v === true || v === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStringSetting(key: string, fallback: string | undefined): Promise<string | undefined> {
|
||||||
|
const v = await getSetting(key);
|
||||||
|
if (v === undefined || v === "") return fallback;
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptSetting(ciphertextB64: string): string {
|
||||||
|
const key = decodeEncryptionKey(env.PRIMORA_ENCRYPTION_KEY);
|
||||||
|
const buf = Buffer.from(ciphertextB64, "base64");
|
||||||
|
const nonce = buf.subarray(0, 12);
|
||||||
|
const body = buf.subarray(12);
|
||||||
|
const tag = body.subarray(body.length - 16);
|
||||||
|
const ciphertext = body.subarray(0, body.length - 16);
|
||||||
|
const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same key formats as the Go Encryptor: 32 bytes as hex or base64.
|
||||||
|
function decodeEncryptionKey(raw: string | undefined): Buffer {
|
||||||
|
if (!raw) throw new Error("PRIMORA_ENCRYPTION_KEY is required to read secret settings");
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
for (const decoded of [
|
||||||
|
Buffer.from(trimmed, "hex"),
|
||||||
|
Buffer.from(trimmed, "base64"),
|
||||||
|
Buffer.from(trimmed, "base64url"),
|
||||||
|
]) {
|
||||||
|
if (decoded.length === 32) return decoded;
|
||||||
|
}
|
||||||
|
throw new Error("PRIMORA_ENCRYPTION_KEY must decode to 32 bytes (hex or base64)");
|
||||||
|
}
|
||||||
@@ -34,6 +34,9 @@ RUN apt-get update \
|
|||||||
&& chmod +x /usr/local/bin/dbx-mcp \
|
&& chmod +x /usr/local/bin/dbx-mcp \
|
||||||
&& rm /tmp/dbx-mcp.tar.gz
|
&& rm /tmp/dbx-mcp.tar.gz
|
||||||
|
|
||||||
|
# Functions runtime — bun static binary from the official image (multi-arch).
|
||||||
|
COPY --from=oven/bun:1.3.14 /usr/local/bin/bun /usr/local/bin/bun
|
||||||
|
|
||||||
COPY --from=builder /primora-backend /usr/local/bin/primora-backend
|
COPY --from=builder /primora-backend /usr/local/bin/primora-backend
|
||||||
COPY --from=builder /app/db ./db
|
COPY --from=builder /app/db ./db
|
||||||
COPY --from=builder /app/openapi ./openapi
|
COPY --from=builder /app/openapi ./openapi
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Instance-level settings. In-app values override env vars; secrets are
|
||||||
|
-- AES-256-GCM ciphertext (base64) produced by the backend's Encryptor and
|
||||||
|
-- readable by the auth service, which shares PRIMORA_ENCRYPTION_KEY.
|
||||||
|
CREATE TABLE core.settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
secret BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
updated_by TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS core.settings;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Project-level secrets: AES-256-GCM ciphertext produced by the backend's
|
||||||
|
-- Encryptor. Values are write-once — list/read expose metadata only; a
|
||||||
|
-- dedicated reveal endpoint decrypts and is audit-logged. secret://NAME
|
||||||
|
-- references in job payloads resolve at delivery time.
|
||||||
|
CREATE TABLE core.project_secrets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
ciphertext BYTEA NOT NULL,
|
||||||
|
url TEXT NOT NULL DEFAULT '',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (project_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS core.project_secrets;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE core.alert_rules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('heartbeat_silence','error_spike')),
|
||||||
|
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
state JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (project_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS core.alert_rules;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE core.inbound_hooks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
mode TEXT NOT NULL DEFAULT 'event' CHECK (mode IN ('event','job')),
|
||||||
|
job_id UUID REFERENCES core.scheduled_jobs(id) ON DELETE CASCADE,
|
||||||
|
secret BYTEA NOT NULL DEFAULT ''::bytea,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_received_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (project_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||||
|
CHECK (triggered_by IN ('schedule','manual','hook'));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||||
|
CHECK (triggered_by IN ('schedule','manual'));
|
||||||
|
DROP TABLE IF EXISTS core.inbound_hooks;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE core.email_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
template TEXT NOT NULL,
|
||||||
|
to_email TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('sent','failed')),
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_email_log_project ON core.email_log(project_id, created_at DESC);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS core.email_log;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE core.functions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
runtime TEXT NOT NULL DEFAULT 'bun' CHECK (runtime IN ('bun','deno')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (project_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE core.function_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
function_id UUID NOT NULL REFERENCES core.functions(id) ON DELETE CASCADE,
|
||||||
|
trigger TEXT NOT NULL CHECK (trigger IN ('manual','schedule','hook')),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('success','error','timeout')),
|
||||||
|
exit_code INT,
|
||||||
|
stdout TEXT NOT NULL DEFAULT '',
|
||||||
|
stderr TEXT NOT NULL DEFAULT '',
|
||||||
|
duration_ms INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX function_runs_function_idx ON core.function_runs(function_id, created_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||||
|
CHECK (triggered_by IN ('schedule','manual','hook','function'));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||||
|
CHECK (triggered_by IN ('schedule','manual','hook'));
|
||||||
|
DROP TABLE IF EXISTS core.function_runs;
|
||||||
|
DROP TABLE IF EXISTS core.functions;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE core.scheduled_jobs
|
||||||
|
ADD COLUMN function_id UUID REFERENCES core.functions(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE core.inbound_hooks
|
||||||
|
ADD COLUMN function_id UUID REFERENCES core.functions(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE core.inbound_hooks
|
||||||
|
DROP CONSTRAINT inbound_hooks_mode_check;
|
||||||
|
ALTER TABLE core.inbound_hooks
|
||||||
|
ADD CONSTRAINT inbound_hooks_mode_check
|
||||||
|
CHECK (mode IN ('event','job','function'));
|
||||||
|
|
||||||
|
ALTER TABLE core.functions
|
||||||
|
ADD COLUMN event_pattern TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE core.function_runs
|
||||||
|
DROP CONSTRAINT function_runs_trigger_check;
|
||||||
|
ALTER TABLE core.function_runs
|
||||||
|
ADD CONSTRAINT function_runs_trigger_check
|
||||||
|
CHECK (trigger IN ('manual','schedule','hook','event'));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE core.function_runs
|
||||||
|
DROP CONSTRAINT function_runs_trigger_check;
|
||||||
|
ALTER TABLE core.function_runs
|
||||||
|
ADD CONSTRAINT function_runs_trigger_check
|
||||||
|
CHECK (trigger IN ('manual','schedule','hook'));
|
||||||
|
ALTER TABLE core.functions DROP COLUMN IF EXISTS event_pattern;
|
||||||
|
ALTER TABLE core.inbound_hooks
|
||||||
|
DROP CONSTRAINT inbound_hooks_mode_check;
|
||||||
|
ALTER TABLE core.inbound_hooks
|
||||||
|
ADD CONSTRAINT inbound_hooks_mode_check CHECK (mode IN ('event','job'));
|
||||||
|
ALTER TABLE core.inbound_hooks DROP COLUMN IF EXISTS function_id;
|
||||||
|
ALTER TABLE core.scheduled_jobs DROP COLUMN IF EXISTS function_id;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- name: CreateAlertRule :one
|
||||||
|
INSERT INTO core.alert_rules (project_id, name, kind, config)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListAlertRules :many
|
||||||
|
SELECT * FROM core.alert_rules
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
-- name: ListEnabledAlertRules :many
|
||||||
|
SELECT * FROM core.alert_rules
|
||||||
|
WHERE enabled;
|
||||||
|
|
||||||
|
-- name: GetAlertRule :one
|
||||||
|
SELECT * FROM core.alert_rules
|
||||||
|
WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- name: UpdateAlertRule :one
|
||||||
|
UPDATE core.alert_rules
|
||||||
|
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: SetAlertRuleState :exec
|
||||||
|
UPDATE core.alert_rules
|
||||||
|
SET state = $3, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- name: DeleteAlertRule :exec
|
||||||
|
DELETE FROM core.alert_rules
|
||||||
|
WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- Per-component most recent heartbeat — heartbeat_silence detection.
|
||||||
|
-- name: LastHeartbeats :many
|
||||||
|
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
|
||||||
|
FROM core.components co
|
||||||
|
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
|
||||||
|
WHERE co.project_id = $1
|
||||||
|
GROUP BY co.name;
|
||||||
|
|
||||||
|
-- Per-component error count inside a sliding window — error_spike detection.
|
||||||
|
-- name: RecentErrorCounts :many
|
||||||
|
SELECT co.name AS component_name, count(*) AS errors
|
||||||
|
FROM core.events e
|
||||||
|
JOIN core.components co ON co.id = e.component_id
|
||||||
|
WHERE e.project_id = $1 AND e.type = 'error'
|
||||||
|
AND e.ts > now() - make_interval(secs => $2::bigint)
|
||||||
|
GROUP BY co.name;
|
||||||
@@ -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,10 @@
|
|||||||
|
-- name: InsertEmailLog :one
|
||||||
|
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListEmailLog :many
|
||||||
|
SELECT * FROM core.email_log
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- name: CreateFunction :one
|
||||||
|
INSERT INTO core.functions (project_id, name, code, runtime, event_pattern)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetFunction :one
|
||||||
|
SELECT * FROM core.functions WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetFunctionByName :one
|
||||||
|
SELECT * FROM core.functions WHERE project_id = $1 AND name = $2;
|
||||||
|
|
||||||
|
-- name: ListFunctions :many
|
||||||
|
SELECT * FROM core.functions
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
-- name: UpdateFunction :one
|
||||||
|
UPDATE core.functions
|
||||||
|
SET code = COALESCE(sqlc.narg('code'), code),
|
||||||
|
enabled = COALESCE(sqlc.narg('enabled'), enabled),
|
||||||
|
event_pattern = COALESCE(sqlc.narg('event_pattern'), event_pattern),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteFunction :exec
|
||||||
|
DELETE FROM core.functions WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- name: InsertFunctionRun :one
|
||||||
|
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListFunctionRuns :many
|
||||||
|
SELECT * FROM core.function_runs
|
||||||
|
WHERE function_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2;
|
||||||
|
|
||||||
|
-- name: ListFunctionsForEvent :many
|
||||||
|
SELECT * FROM core.functions
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND enabled = TRUE
|
||||||
|
AND event_pattern <> ''
|
||||||
|
AND ($2 LIKE REPLACE(event_pattern, '*', '%') OR event_pattern = '*');
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- name: CreateInboundHook :one
|
||||||
|
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, function_id, secret, enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListInboundHooks :many
|
||||||
|
SELECT * FROM core.inbound_hooks
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
-- name: GetInboundHookByToken :one
|
||||||
|
SELECT * FROM core.inbound_hooks
|
||||||
|
WHERE token = $1;
|
||||||
|
|
||||||
|
-- name: GetInboundHook :one
|
||||||
|
SELECT * FROM core.inbound_hooks
|
||||||
|
WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- name: TouchInboundHook :exec
|
||||||
|
UPDATE core.inbound_hooks
|
||||||
|
SET last_received_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: DeleteInboundHook :exec
|
||||||
|
DELETE FROM core.inbound_hooks
|
||||||
|
WHERE id = $1 AND project_id = $2;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
-- 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,
|
||||||
|
function_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
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,
|
||||||
|
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id 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
|
||||||
|
);
|
||||||
@@ -147,3 +147,7 @@ WHERE p.organization_id = $1;
|
|||||||
DELETE FROM core.organizations
|
DELETE FROM core.organizations
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetOrganizationByID :one
|
||||||
|
SELECT * FROM core.organizations
|
||||||
|
WHERE id = $1;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- name: ListProjectSecrets :many
|
||||||
|
SELECT id, project_id, name, url, notes, created_at, updated_at
|
||||||
|
FROM core.project_secrets
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name ASC;
|
||||||
|
|
||||||
|
-- name: GetProjectSecret :one
|
||||||
|
SELECT * FROM core.project_secrets
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND name = $2;
|
||||||
|
|
||||||
|
-- name: UpsertProjectSecret :one
|
||||||
|
INSERT INTO core.project_secrets (
|
||||||
|
project_id,
|
||||||
|
name,
|
||||||
|
ciphertext,
|
||||||
|
url,
|
||||||
|
notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (project_id, name) DO UPDATE SET
|
||||||
|
ciphertext = EXCLUDED.ciphertext,
|
||||||
|
url = EXCLUDED.url,
|
||||||
|
notes = EXCLUDED.notes,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteProjectSecret :one
|
||||||
|
DELETE FROM core.project_secrets
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND name = $2
|
||||||
|
RETURNING id;
|
||||||
|
|
||||||
|
-- name: ListProjectSecretValues :many
|
||||||
|
SELECT name, ciphertext
|
||||||
|
FROM core.project_secrets
|
||||||
|
WHERE project_id = $1;
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- name: GetSetting :one
|
||||||
|
SELECT key, value, secret, updated_at FROM core.settings WHERE key = $1;
|
||||||
|
|
||||||
|
-- name: ListSettings :many
|
||||||
|
SELECT key, value, secret, updated_at FROM core.settings ORDER BY key;
|
||||||
|
|
||||||
|
-- name: UpsertSetting :one
|
||||||
|
INSERT INTO core.settings (key, value, secret, updated_by)
|
||||||
|
VALUES ($1, $2::jsonb, $3, $4)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value,
|
||||||
|
secret = EXCLUDED.secret,
|
||||||
|
updated_by = EXCLUDED.updated_by,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteSetting :exec
|
||||||
|
DELETE FROM core.settings WHERE key = $1;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ WHERE id = $1;
|
|||||||
SELECT * FROM core.webhooks
|
SELECT * FROM core.webhooks
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
AND enabled
|
AND enabled
|
||||||
AND (cardinality(events) = 0 OR $2::text = ANY(events));
|
AND (cardinality(events) = 0 OR EXISTS (
|
||||||
|
SELECT 1 FROM unnest(events) AS e
|
||||||
|
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
|
||||||
|
));
|
||||||
|
|
||||||
-- name: CreateWebhook :one
|
-- name: CreateWebhook :one
|
||||||
INSERT INTO core.webhooks (
|
INSERT INTO core.webhooks (
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -78,9 +81,31 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := storage.NewLocalStore(cfg.StorageRoot)
|
var store storage.Store
|
||||||
if err != nil {
|
if cfg.StorageDriver == "s3" {
|
||||||
return nil, err
|
s3Store, err := storage.NewS3Store(storage.S3Config{
|
||||||
|
Endpoint: cfg.S3Endpoint,
|
||||||
|
Region: cfg.S3Region,
|
||||||
|
Bucket: cfg.S3Bucket,
|
||||||
|
AccessKeyID: cfg.S3AccessKeyID,
|
||||||
|
SecretAccessKey: cfg.S3SecretAccessKey,
|
||||||
|
PublicEndpoint: cfg.S3PublicEndpoint,
|
||||||
|
Prefix: cfg.S3Prefix,
|
||||||
|
PathStyle: cfg.S3PathStyle,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("s3 storage: %w", err)
|
||||||
|
}
|
||||||
|
if err := s3Store.EnsureBucket(context.Background()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
store = s3Store
|
||||||
|
} else {
|
||||||
|
localStore, err := storage.NewLocalStore(cfg.StorageRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
store = localStore
|
||||||
}
|
}
|
||||||
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
|
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
|
||||||
repo := repositories.NewCoreRepository(dbPool)
|
repo := repositories.NewCoreRepository(dbPool)
|
||||||
@@ -88,7 +113,14 @@ 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)
|
var fnRunner services.FunctionRunner
|
||||||
|
if cfg.FunctionsDriver == "docker" {
|
||||||
|
fnRunner = services.NewDockerRunner(cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
|
||||||
|
} else {
|
||||||
|
fnRunner = services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
|
||||||
|
}
|
||||||
|
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, fnRunner)
|
||||||
|
settings := services.NewSettingsService(repo, encryptor)
|
||||||
|
|
||||||
if cfg.Env == "production" {
|
if cfg.Env == "production" {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
@@ -123,10 +155,12 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
|||||||
APIKeyPerMinute: cfg.APIKeyRateLimitPerMin,
|
APIKeyPerMinute: cfg.APIKeyRateLimitPerMin,
|
||||||
UserPerMinute: cfg.UserRateLimitPerMin,
|
UserPerMinute: cfg.UserRateLimitPerMin,
|
||||||
},
|
},
|
||||||
|
RateLimitResolver: settings.RateLimitPerMinute,
|
||||||
}.ResolveActor())
|
}.ResolveActor())
|
||||||
|
|
||||||
handler := &handlers.HTTPHandler{
|
handler := &handlers.HTTPHandler{
|
||||||
Platform: platform,
|
Platform: platform,
|
||||||
|
Settings: settings,
|
||||||
Validate: validator.New(),
|
Validate: validator.New(),
|
||||||
Metrics: metrics,
|
Metrics: metrics,
|
||||||
Readiness: func(c *gin.Context) map[string]any {
|
Readiness: func(c *gin.Context) map[string]any {
|
||||||
@@ -157,55 +191,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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -35,6 +36,26 @@ type Config struct {
|
|||||||
SMTPSecure bool
|
SMTPSecure bool
|
||||||
|
|
||||||
EncryptionKey string
|
EncryptionKey string
|
||||||
|
|
||||||
|
// Object storage: "local" (BACKEND_STORAGE_ROOT) or "s3".
|
||||||
|
StorageDriver string
|
||||||
|
S3Endpoint string
|
||||||
|
S3Region string
|
||||||
|
S3Bucket string
|
||||||
|
S3AccessKeyID string
|
||||||
|
S3SecretAccessKey string
|
||||||
|
S3PublicEndpoint string // presigned-URL host when it differs from Endpoint
|
||||||
|
S3Prefix string
|
||||||
|
S3PathStyle bool
|
||||||
|
|
||||||
|
// Functions runtime: driver "exec" (runtime binary on the backend host)
|
||||||
|
// or "docker" (ephemeral locked-down container), binary name/path for
|
||||||
|
// exec ("auto" resolves per-function runtime from PATH), timeout and
|
||||||
|
// output cap.
|
||||||
|
FunctionsDriver string
|
||||||
|
FunctionsRuntime string
|
||||||
|
FunctionsTimeout time.Duration
|
||||||
|
FunctionsMaxBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// devEncryptionKey is the built-in credential key for local development only.
|
// devEncryptionKey is the built-in credential key for local development only.
|
||||||
@@ -63,6 +84,17 @@ func Load() (Config, error) {
|
|||||||
SMTPUser: os.Getenv("SMTP_USER"),
|
SMTPUser: os.Getenv("SMTP_USER"),
|
||||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||||
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
|
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
|
||||||
|
StorageDriver: getenv("BACKEND_STORAGE_DRIVER", "local"),
|
||||||
|
S3Endpoint: os.Getenv("S3_ENDPOINT"),
|
||||||
|
S3Region: getenv("S3_REGION", "us-east-1"),
|
||||||
|
S3Bucket: os.Getenv("S3_BUCKET"),
|
||||||
|
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
|
||||||
|
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
||||||
|
S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"),
|
||||||
|
S3Prefix: os.Getenv("S3_PREFIX"),
|
||||||
|
FunctionsDriver: getenv("FUNCTIONS_DRIVER", "exec"),
|
||||||
|
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
|
||||||
|
FunctionsMaxBytes: 65536,
|
||||||
}
|
}
|
||||||
|
|
||||||
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
||||||
@@ -95,6 +127,24 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.SMTPSecure = smtpSecure
|
cfg.SMTPSecure = smtpSecure
|
||||||
|
|
||||||
|
s3PathStyle, err := strconv.ParseBool(getenv("S3_PATH_STYLE", "true"))
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("parse S3_PATH_STYLE: %w", err)
|
||||||
|
}
|
||||||
|
cfg.S3PathStyle = s3PathStyle
|
||||||
|
|
||||||
|
fnTimeout, err := strconv.Atoi(getenv("FUNCTIONS_TIMEOUT_SECONDS", "30"))
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("parse FUNCTIONS_TIMEOUT_SECONDS: %w", err)
|
||||||
|
}
|
||||||
|
cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second
|
||||||
|
|
||||||
|
switch cfg.FunctionsDriver {
|
||||||
|
case "exec", "docker":
|
||||||
|
default:
|
||||||
|
return Config{}, fmt.Errorf("FUNCTIONS_DRIVER %q must be exec or docker", cfg.FunctionsDriver)
|
||||||
|
}
|
||||||
|
|
||||||
var missing []string
|
var missing []string
|
||||||
if cfg.DatabaseURL == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
missing = append(missing, "DATABASE_URL")
|
missing = append(missing, "DATABASE_URL")
|
||||||
@@ -102,8 +152,23 @@ func Load() (Config, error) {
|
|||||||
if cfg.JWTSecret == "" {
|
if cfg.JWTSecret == "" {
|
||||||
missing = append(missing, "JWT_SECRET")
|
missing = append(missing, "JWT_SECRET")
|
||||||
}
|
}
|
||||||
if cfg.StorageRoot == "" {
|
switch cfg.StorageDriver {
|
||||||
missing = append(missing, "BACKEND_STORAGE_ROOT")
|
case "local":
|
||||||
|
if cfg.StorageRoot == "" {
|
||||||
|
missing = append(missing, "BACKEND_STORAGE_ROOT")
|
||||||
|
}
|
||||||
|
case "s3":
|
||||||
|
if cfg.S3Bucket == "" {
|
||||||
|
missing = append(missing, "S3_BUCKET")
|
||||||
|
}
|
||||||
|
if cfg.S3AccessKeyID == "" {
|
||||||
|
missing = append(missing, "S3_ACCESS_KEY_ID")
|
||||||
|
}
|
||||||
|
if cfg.S3SecretAccessKey == "" {
|
||||||
|
missing = append(missing, "S3_SECRET_ACCESS_KEY")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return Config{}, fmt.Errorf("unknown BACKEND_STORAGE_DRIVER %q (want local or s3)", cfg.StorageDriver)
|
||||||
}
|
}
|
||||||
if cfg.AuthInternalBaseURL == "" {
|
if cfg.AuthInternalBaseURL == "" {
|
||||||
missing = append(missing, "AUTH_INTERNAL_BASE_URL")
|
missing = append(missing, "AUTH_INTERNAL_BASE_URL")
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: alert_rules.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createAlertRule = `-- name: CreateAlertRule :one
|
||||||
|
INSERT INTO core.alert_rules (project_id, name, kind, config)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateAlertRuleParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Config []byte `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createAlertRule,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Kind,
|
||||||
|
arg.Config,
|
||||||
|
)
|
||||||
|
var i CoreAlertRule
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAlertRule = `-- name: DeleteAlertRule :exec
|
||||||
|
DELETE FROM core.alert_rules
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteAlertRuleParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteAlertRule, arg.ID, arg.ProjectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAlertRule = `-- name: GetAlertRule :one
|
||||||
|
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAlertRuleParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getAlertRule, arg.ID, arg.ProjectID)
|
||||||
|
var i CoreAlertRule
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastHeartbeats = `-- name: LastHeartbeats :many
|
||||||
|
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
|
||||||
|
FROM core.components co
|
||||||
|
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
|
||||||
|
WHERE co.project_id = $1
|
||||||
|
GROUP BY co.name
|
||||||
|
`
|
||||||
|
|
||||||
|
type LastHeartbeatsRow struct {
|
||||||
|
ComponentName string `json:"component_name"`
|
||||||
|
LastBeat pgtype.Timestamptz `json:"last_beat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-component most recent heartbeat — heartbeat_silence detection.
|
||||||
|
func (q *Queries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, lastHeartbeats, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []LastHeartbeatsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i LastHeartbeatsRow
|
||||||
|
if err := rows.Scan(&i.ComponentName, &i.LastBeat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAlertRules = `-- name: ListAlertRules :many
|
||||||
|
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAlertRules, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreAlertRule
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreAlertRule
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&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 listEnabledAlertRules = `-- name: ListEnabledAlertRules :many
|
||||||
|
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||||
|
WHERE enabled
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listEnabledAlertRules)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreAlertRule
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreAlertRule
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&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 recentErrorCounts = `-- name: RecentErrorCounts :many
|
||||||
|
SELECT co.name AS component_name, count(*) AS errors
|
||||||
|
FROM core.events e
|
||||||
|
JOIN core.components co ON co.id = e.component_id
|
||||||
|
WHERE e.project_id = $1 AND e.type = 'error'
|
||||||
|
AND e.ts > now() - make_interval(secs => $2::bigint)
|
||||||
|
GROUP BY co.name
|
||||||
|
`
|
||||||
|
|
||||||
|
type RecentErrorCountsParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Column2 int64 `json:"column_2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecentErrorCountsRow struct {
|
||||||
|
ComponentName string `json:"component_name"`
|
||||||
|
Errors int64 `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-component error count inside a sliding window — error_spike detection.
|
||||||
|
func (q *Queries) RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, recentErrorCounts, arg.ProjectID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecentErrorCountsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecentErrorCountsRow
|
||||||
|
if err := rows.Scan(&i.ComponentName, &i.Errors); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAlertRuleState = `-- name: SetAlertRuleState :exec
|
||||||
|
UPDATE core.alert_rules
|
||||||
|
SET state = $3, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetAlertRuleStateParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
State []byte `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, setAlertRuleState, arg.ID, arg.ProjectID, arg.State)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAlertRule = `-- name: UpdateAlertRule :one
|
||||||
|
UPDATE core.alert_rules
|
||||||
|
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateAlertRuleParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Config []byte `json:"config"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateAlertRule,
|
||||||
|
arg.ID,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Config,
|
||||||
|
arg.Enabled,
|
||||||
|
)
|
||||||
|
var i CoreAlertRule
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -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,91 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: email_log.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const insertEmailLog = `-- name: InsertEmailLog :one
|
||||||
|
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, project_id, template, to_email, subject, status, error, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertEmailLogParams struct {
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
ToEmail string `json:"to_email"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertEmailLog,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Template,
|
||||||
|
arg.ToEmail,
|
||||||
|
arg.Subject,
|
||||||
|
arg.Status,
|
||||||
|
arg.Error,
|
||||||
|
)
|
||||||
|
var i CoreEmailLog
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Template,
|
||||||
|
&i.ToEmail,
|
||||||
|
&i.Subject,
|
||||||
|
&i.Status,
|
||||||
|
&i.Error,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEmailLog = `-- name: ListEmailLog :many
|
||||||
|
SELECT id, project_id, template, to_email, subject, status, error, created_at FROM core.email_log
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListEmailLogParams struct {
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listEmailLog, arg.ProjectID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreEmailLog
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreEmailLog
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Template,
|
||||||
|
&i.ToEmail,
|
||||||
|
&i.Subject,
|
||||||
|
&i.Status,
|
||||||
|
&i.Error,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: functions.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createFunction = `-- name: CreateFunction :one
|
||||||
|
INSERT INTO core.functions (project_id, name, code, runtime, event_pattern)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateFunctionParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
EventPattern string `json:"event_pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createFunction,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Code,
|
||||||
|
arg.Runtime,
|
||||||
|
arg.EventPattern,
|
||||||
|
)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFunction = `-- name: DeleteFunction :exec
|
||||||
|
DELETE FROM core.functions WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteFunctionParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteFunction, arg.ID, arg.ProjectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFunction = `-- name: GetFunction :one
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFunction, id)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFunctionByName = `-- name: GetFunctionByName :one
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE project_id = $1 AND name = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetFunctionByNameParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFunctionByName, arg.ProjectID, arg.Name)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertFunctionRun = `-- name: InsertFunctionRun :one
|
||||||
|
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertFunctionRunParams struct {
|
||||||
|
FunctionID uuid.UUID `json:"function_id"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode *int32 `json:"exit_code"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertFunctionRun,
|
||||||
|
arg.FunctionID,
|
||||||
|
arg.Trigger,
|
||||||
|
arg.Status,
|
||||||
|
arg.ExitCode,
|
||||||
|
arg.Stdout,
|
||||||
|
arg.Stderr,
|
||||||
|
arg.DurationMs,
|
||||||
|
)
|
||||||
|
var i CoreFunctionRun
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FunctionID,
|
||||||
|
&i.Trigger,
|
||||||
|
&i.Status,
|
||||||
|
&i.ExitCode,
|
||||||
|
&i.Stdout,
|
||||||
|
&i.Stderr,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFunctionRuns = `-- name: ListFunctionRuns :many
|
||||||
|
SELECT id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at FROM core.function_runs
|
||||||
|
WHERE function_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListFunctionRunsParams struct {
|
||||||
|
FunctionID uuid.UUID `json:"function_id"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listFunctionRuns, arg.FunctionID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreFunctionRun
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreFunctionRun
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FunctionID,
|
||||||
|
&i.Trigger,
|
||||||
|
&i.Status,
|
||||||
|
&i.ExitCode,
|
||||||
|
&i.Stdout,
|
||||||
|
&i.Stderr,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFunctions = `-- name: ListFunctions :many
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listFunctions, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreFunction
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreFunction
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFunctionsForEvent = `-- name: ListFunctionsForEvent :many
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND enabled = TRUE
|
||||||
|
AND event_pattern <> ''
|
||||||
|
AND ($2 LIKE REPLACE(event_pattern, '*', '%') OR event_pattern = '*')
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListFunctionsForEventParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
EventPattern string `json:"event_pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listFunctionsForEvent, arg.ProjectID, arg.EventPattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreFunction
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreFunction
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFunction = `-- name: UpdateFunction :one
|
||||||
|
UPDATE core.functions
|
||||||
|
SET code = COALESCE($3, code),
|
||||||
|
enabled = COALESCE($4, enabled),
|
||||||
|
event_pattern = COALESCE($5, event_pattern),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateFunctionParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Code *string `json:"code"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
EventPattern *string `json:"event_pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateFunction,
|
||||||
|
arg.ID,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Code,
|
||||||
|
arg.Enabled,
|
||||||
|
arg.EventPattern,
|
||||||
|
)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: inbound_hooks.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createInboundHook = `-- name: CreateInboundHook :one
|
||||||
|
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, function_id, secret, enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateInboundHookParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
JobID pgtype.UUID `json:"job_id"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
|
Secret []byte `json:"secret"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createInboundHook,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Token,
|
||||||
|
arg.Mode,
|
||||||
|
arg.JobID,
|
||||||
|
arg.FunctionID,
|
||||||
|
arg.Secret,
|
||||||
|
arg.Enabled,
|
||||||
|
)
|
||||||
|
var i CoreInboundHook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Token,
|
||||||
|
&i.Mode,
|
||||||
|
&i.JobID,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastReceivedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteInboundHook = `-- name: DeleteInboundHook :exec
|
||||||
|
DELETE FROM core.inbound_hooks
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteInboundHookParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteInboundHook, arg.ID, arg.ProjectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInboundHook = `-- name: GetInboundHook :one
|
||||||
|
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetInboundHookParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getInboundHook, arg.ID, arg.ProjectID)
|
||||||
|
var i CoreInboundHook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Token,
|
||||||
|
&i.Mode,
|
||||||
|
&i.JobID,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastReceivedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInboundHookByToken = `-- name: GetInboundHookByToken :one
|
||||||
|
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||||
|
WHERE token = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getInboundHookByToken, token)
|
||||||
|
var i CoreInboundHook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Token,
|
||||||
|
&i.Mode,
|
||||||
|
&i.JobID,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastReceivedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listInboundHooks = `-- name: ListInboundHooks :many
|
||||||
|
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listInboundHooks, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreInboundHook
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreInboundHook
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Token,
|
||||||
|
&i.Mode,
|
||||||
|
&i.JobID,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastReceivedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchInboundHook = `-- name: TouchInboundHook :exec
|
||||||
|
UPDATE core.inbound_hooks
|
||||||
|
SET last_received_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) TouchInboundHook(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, touchInboundHook, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
// 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,
|
||||||
|
function_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
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, function_id
|
||||||
|
`
|
||||||
|
|
||||||
|
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"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_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,
|
||||||
|
arg.FunctionID,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
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, function_id 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,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
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, function_id 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,
|
||||||
|
&i.FunctionID,
|
||||||
|
); 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, function_id 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,
|
||||||
|
&i.FunctionID,
|
||||||
|
); 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, function_id
|
||||||
|
`
|
||||||
|
|
||||||
|
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,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id 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, function_id
|
||||||
|
`
|
||||||
|
|
||||||
|
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"`
|
||||||
|
Column12 bool `json:"column_12"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
arg.Column12,
|
||||||
|
arg.FunctionID,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -140,6 +140,18 @@ func (ns NullCoreProjectRole) Value() (driver.Value, error) {
|
|||||||
return string(ns.CoreProjectRole), nil
|
return string(ns.CoreProjectRole), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreAlertRule struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Config []byte `json:"config"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
State []byte `json:"state"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreApiKey struct {
|
type CoreApiKey struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -150,6 +162,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 {
|
||||||
@@ -230,6 +243,17 @@ type CoreDocument struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreEmailLog struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
ToEmail string `json:"to_email"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreEvent struct {
|
type CoreEvent struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -242,6 +266,45 @@ type CoreEvent struct {
|
|||||||
Ts pgtype.Timestamptz `json:"ts"`
|
Ts pgtype.Timestamptz `json:"ts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreFunction struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
EventPattern string `json:"event_pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoreFunctionRun struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
FunctionID uuid.UUID `json:"function_id"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode *int32 `json:"exit_code"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoreInboundHook struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
JobID pgtype.UUID `json:"job_id"`
|
||||||
|
Secret []byte `json:"secret"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastReceivedAt pgtype.Timestamptz `json:"last_received_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreIntegration struct {
|
type CoreIntegration struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -273,12 +336,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 +369,55 @@ type CoreProjectMember struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreProjectSecret struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Ciphertext []byte `json:"ciphertext"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_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"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 CoreSetting struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value []byte `json:"value"`
|
||||||
|
Secret bool `json:"secret"`
|
||||||
|
UpdatedBy *string `json:"updated_by"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_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"`
|
||||||
|
|||||||
@@ -241,6 +241,23 @@ func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getOrganizationByID = `-- name: GetOrganizationByID :one
|
||||||
|
SELECT id, slug, name, created_at FROM core.organizations
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getOrganizationByID, id)
|
||||||
|
var i CoreOrganization
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Name,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
|
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
|
||||||
SELECT
|
SELECT
|
||||||
om.id, om.organization_id, om.user_id, om.role, om.created_at,
|
om.id, om.organization_id, om.user_id, om.role, om.created_at,
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: project_secrets.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteProjectSecret = `-- name: DeleteProjectSecret :one
|
||||||
|
DELETE FROM core.project_secrets
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND name = $2
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteProjectSecretParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error) {
|
||||||
|
row := q.db.QueryRow(ctx, deleteProjectSecret, arg.ProjectID, arg.Name)
|
||||||
|
var id uuid.UUID
|
||||||
|
err := row.Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProjectSecret = `-- name: GetProjectSecret :one
|
||||||
|
SELECT id, project_id, name, ciphertext, url, notes, created_at, updated_at FROM core.project_secrets
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND name = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetProjectSecretParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getProjectSecret, arg.ProjectID, arg.Name)
|
||||||
|
var i CoreProjectSecret
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Ciphertext,
|
||||||
|
&i.Url,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listProjectSecretValues = `-- name: ListProjectSecretValues :many
|
||||||
|
SELECT name, ciphertext
|
||||||
|
FROM core.project_secrets
|
||||||
|
WHERE project_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListProjectSecretValuesRow struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Ciphertext []byte `json:"ciphertext"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listProjectSecretValues, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListProjectSecretValuesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListProjectSecretValuesRow
|
||||||
|
if err := rows.Scan(&i.Name, &i.Ciphertext); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listProjectSecrets = `-- name: ListProjectSecrets :many
|
||||||
|
SELECT id, project_id, name, url, notes, created_at, updated_at
|
||||||
|
FROM core.project_secrets
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListProjectSecretsRow struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listProjectSecrets, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListProjectSecretsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListProjectSecretsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Url,
|
||||||
|
&i.Notes,
|
||||||
|
&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 upsertProjectSecret = `-- name: UpsertProjectSecret :one
|
||||||
|
INSERT INTO core.project_secrets (
|
||||||
|
project_id,
|
||||||
|
name,
|
||||||
|
ciphertext,
|
||||||
|
url,
|
||||||
|
notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (project_id, name) DO UPDATE SET
|
||||||
|
ciphertext = EXCLUDED.ciphertext,
|
||||||
|
url = EXCLUDED.url,
|
||||||
|
notes = EXCLUDED.notes,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, project_id, name, ciphertext, url, notes, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertProjectSecretParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Ciphertext []byte `json:"ciphertext"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error) {
|
||||||
|
row := q.db.QueryRow(ctx, upsertProjectSecret,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Ciphertext,
|
||||||
|
arg.Url,
|
||||||
|
arg.Notes,
|
||||||
|
)
|
||||||
|
var i CoreProjectSecret
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Ciphertext,
|
||||||
|
&i.Url,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -22,32 +23,44 @@ type Querier interface {
|
|||||||
CountOrganizations(ctx context.Context) (int64, error)
|
CountOrganizations(ctx context.Context) (int64, error)
|
||||||
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
|
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||||
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
|
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
|
||||||
|
CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error)
|
||||||
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
|
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
|
||||||
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
|
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
|
||||||
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
|
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
|
||||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
||||||
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
||||||
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
||||||
|
CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error)
|
||||||
|
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
|
||||||
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
||||||
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)
|
||||||
|
DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) 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)
|
||||||
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
||||||
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
||||||
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
||||||
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
||||||
|
DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error
|
||||||
|
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
|
||||||
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
||||||
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)
|
||||||
|
DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error)
|
||||||
|
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
|
||||||
|
DeleteSetting(ctx context.Context, key string) 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)
|
||||||
|
GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error)
|
||||||
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
|
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
|
||||||
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||||
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
|
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
|
||||||
@@ -56,21 +69,35 @@ type Querier interface {
|
|||||||
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
||||||
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
||||||
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
||||||
|
GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error)
|
||||||
|
GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error)
|
||||||
|
GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error)
|
||||||
|
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error)
|
||||||
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
|
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
|
||||||
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||||
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
||||||
|
GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||||
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
|
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
|
||||||
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)
|
||||||
|
GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error)
|
||||||
|
GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error)
|
||||||
|
GetSetting(ctx context.Context, key string) (GetSettingRow, 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)
|
||||||
|
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error)
|
||||||
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
||||||
|
InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, 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)
|
||||||
|
// Per-component most recent heartbeat — heartbeat_silence detection.
|
||||||
|
LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error)
|
||||||
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
|
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
|
||||||
|
ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error)
|
||||||
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
||||||
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
||||||
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
|
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
|
||||||
@@ -79,38 +106,70 @@ 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)
|
||||||
|
ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error)
|
||||||
|
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, 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)
|
||||||
|
ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error)
|
||||||
|
ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error)
|
||||||
|
ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error)
|
||||||
|
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
|
||||||
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
||||||
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
||||||
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
||||||
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
|
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
|
||||||
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)
|
||||||
|
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error)
|
||||||
|
ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, 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)
|
||||||
|
ListSettings(ctx context.Context) ([]ListSettingsRow, 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
|
||||||
|
// Per-component error count inside a sliding window — error_spike detection.
|
||||||
|
RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, 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)
|
||||||
|
SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) 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
|
||||||
|
TouchInboundHook(ctx context.Context, id uuid.UUID) error
|
||||||
|
UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, 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)
|
||||||
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
||||||
|
UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error)
|
||||||
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
|
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
|
||||||
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
|
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
|
||||||
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)
|
||||||
|
UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error)
|
||||||
|
UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: settings.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteSetting = `-- name: DeleteSetting :exec
|
||||||
|
DELETE FROM core.settings WHERE key = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteSetting(ctx context.Context, key string) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteSetting, key)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSetting = `-- name: GetSetting :one
|
||||||
|
SELECT key, value, secret, updated_at FROM core.settings WHERE key = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetSettingRow struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value []byte `json:"value"`
|
||||||
|
Secret bool `json:"secret"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetSetting(ctx context.Context, key string) (GetSettingRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSetting, key)
|
||||||
|
var i GetSettingRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Key,
|
||||||
|
&i.Value,
|
||||||
|
&i.Secret,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listSettings = `-- name: ListSettings :many
|
||||||
|
SELECT key, value, secret, updated_at FROM core.settings ORDER BY key
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListSettingsRow struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value []byte `json:"value"`
|
||||||
|
Secret bool `json:"secret"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListSettings(ctx context.Context) ([]ListSettingsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listSettings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListSettingsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListSettingsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Key,
|
||||||
|
&i.Value,
|
||||||
|
&i.Secret,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertSetting = `-- name: UpsertSetting :one
|
||||||
|
INSERT INTO core.settings (key, value, secret, updated_by)
|
||||||
|
VALUES ($1, $2::jsonb, $3, $4)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value,
|
||||||
|
secret = EXCLUDED.secret,
|
||||||
|
updated_by = EXCLUDED.updated_by,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING key, value, secret, updated_by, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertSettingParams struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Column2 []byte `json:"column_2"`
|
||||||
|
Secret bool `json:"secret"`
|
||||||
|
UpdatedBy *string `json:"updated_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error) {
|
||||||
|
row := q.db.QueryRow(ctx, upsertSetting,
|
||||||
|
arg.Key,
|
||||||
|
arg.Column2,
|
||||||
|
arg.Secret,
|
||||||
|
arg.UpdatedBy,
|
||||||
|
)
|
||||||
|
var i CoreSetting
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Key,
|
||||||
|
&i.Value,
|
||||||
|
&i.Secret,
|
||||||
|
&i.UpdatedBy,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -291,7 +291,10 @@ const listWebhooksForEvent = `-- name: ListWebhooksForEvent :many
|
|||||||
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
|
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
AND enabled
|
AND enabled
|
||||||
AND (cardinality(events) = 0 OR $2::text = ANY(events))
|
AND (cardinality(events) = 0 OR EXISTS (
|
||||||
|
SELECT 1 FROM unnest(events) AS e
|
||||||
|
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
|
||||||
|
))
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListWebhooksForEventParams struct {
|
type ListWebhooksForEventParams struct {
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *HTTPHandler) alertError(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "alert rule not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "invalid") || strings.Contains(msg, "must be") || strings.Contains(msg, "already exists") || strings.Contains(msg, "too long") {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listAlertRules(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListAlertRules(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.alertError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) createAlertRule(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.UpsertAlertRuleInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.CreateAlertRule(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.alertError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) updateAlertRule(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ruleID, err := uuid.Parse(c.Param("ruleID"))
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.UpsertAlertRuleInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.UpdateAlertRule(c.Request.Context(), actor, projectID, ruleID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.alertError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteAlertRule(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ruleID, err := uuid.Parse(c.Param("ruleID"))
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteAlertRule(c.Request.Context(), actor, projectID, ruleID, middleware.RequestIDFromContext(c)); err != nil {
|
||||||
|
h.alertError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// publishEvent accepts a client-authored domain event (custom.* namespace)
|
||||||
|
// and fans it out through the standard publishEvent path — realtime
|
||||||
|
// subscribers, matching webhooks, and event_pattern functions all fire.
|
||||||
|
func (h *HTTPHandler) publishEvent(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Type string `json:"type" validate:"required"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.PublishProjectEvent(c.Request.Context(), actor, projectID, body.Type, body.Data); err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"published": body.Type})
|
||||||
|
}
|
||||||
|
|
||||||
|
// realtimePresence returns the count of clients currently subscribed to the
|
||||||
|
// project's realtime stream.
|
||||||
|
func (h *HTTPHandler) realtimePresence(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.Platform.Presence(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"online": n})
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listFunctions(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.Platform.ListFunctions(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) createFunction(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.CreateFunctionInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn, err := h.Platform.CreateFunction(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) getFunction(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn, code, err := h.Platform.GetFunctionCode(c.Request.Context(), actor, functionID)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"function": fn, "code": code})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) updateFunction(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.UpdateFunctionInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn, err := h.Platform.UpdateFunction(c.Request.Context(), actor, functionID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteFunction(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteFunction(c.Request.Context(), actor, functionID, middleware.RequestIDFromContext(c)); err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) invokeFunction(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
// An empty body is a valid invoke — defaults to {}.
|
||||||
|
if c.Request.Body != nil && c.Request.ContentLength > 0 {
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run, err := h.Platform.InvokeFunction(c.Request.Context(), actor, functionID, body.Payload, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := http.StatusOK
|
||||||
|
if run.Status != "success" {
|
||||||
|
status = http.StatusBadGateway
|
||||||
|
}
|
||||||
|
c.JSON(status, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listFunctionRuns(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.ParseInt(c.DefaultQuery("limit", "50"), 10, 64)
|
||||||
|
runs, err := h.Platform.ListFunctionRuns(c.Request.Context(), actor, functionID, limit)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": runs})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -11,7 +12,9 @@ import (
|
|||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/observability"
|
"github.com/tdvorak/primora/apps/backend/internal/observability"
|
||||||
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
@@ -20,6 +23,7 @@ import (
|
|||||||
|
|
||||||
type HTTPHandler struct {
|
type HTTPHandler struct {
|
||||||
Platform *services.PlatformService
|
Platform *services.PlatformService
|
||||||
|
Settings *services.SettingsService
|
||||||
Validate *validator.Validate
|
Validate *validator.Validate
|
||||||
Metrics *observability.Metrics
|
Metrics *observability.Metrics
|
||||||
Readiness func(*gin.Context) map[string]any
|
Readiness func(*gin.Context) map[string]any
|
||||||
@@ -29,9 +33,15 @@ 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)
|
||||||
|
api.GET("/context", h.actorContext)
|
||||||
|
api.GET("/instance/public", h.instancePublic)
|
||||||
|
api.GET("/instance/settings", h.listInstanceSettings)
|
||||||
|
api.PUT("/instance/settings/:key", h.updateInstanceSetting)
|
||||||
|
api.DELETE("/instance/settings/:key", h.deleteInstanceSetting)
|
||||||
api.POST("/bootstrap", h.bootstrap)
|
api.POST("/bootstrap", h.bootstrap)
|
||||||
api.GET("/organizations", h.organizations)
|
api.GET("/organizations", h.organizations)
|
||||||
api.POST("/organizations", h.createOrganization)
|
api.POST("/organizations", h.createOrganization)
|
||||||
@@ -72,7 +82,20 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
||||||
api.OPTIONS("/ingest", h.ingestOptions)
|
api.OPTIONS("/ingest", h.ingestOptions)
|
||||||
api.POST("/ingest", h.ingest)
|
api.POST("/ingest", h.ingest)
|
||||||
|
api.POST("/hooks/:token", h.receiveInboundHook)
|
||||||
|
api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks)
|
||||||
|
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
|
||||||
|
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
|
||||||
|
api.GET("/projects/:projectID/emails", h.listEmailLog)
|
||||||
|
api.GET("/projects/:projectID/functions", h.listFunctions)
|
||||||
|
api.POST("/projects/:projectID/functions", h.createFunction)
|
||||||
|
api.GET("/projects/:projectID/functions/:functionID", h.getFunction)
|
||||||
|
api.PUT("/projects/:projectID/functions/:functionID", h.updateFunction)
|
||||||
|
api.DELETE("/projects/:projectID/functions/:functionID", h.deleteFunction)
|
||||||
|
api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction)
|
||||||
|
api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns)
|
||||||
api.GET("/projects/:projectID/events", h.listEvents)
|
api.GET("/projects/:projectID/events", h.listEvents)
|
||||||
|
api.POST("/projects/:projectID/events", h.publishEvent)
|
||||||
api.GET("/projects/:projectID/issues", h.listIssues)
|
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||||
api.GET("/projects/:projectID/components", h.listComponents)
|
api.GET("/projects/:projectID/components", h.listComponents)
|
||||||
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
|
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
|
||||||
@@ -91,6 +114,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,9 +127,27 @@ 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/secrets", h.listProjectSecrets)
|
||||||
|
api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret)
|
||||||
|
api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret)
|
||||||
|
api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret)
|
||||||
|
|
||||||
|
api.GET("/projects/:projectID/alerts", h.listAlertRules)
|
||||||
|
api.POST("/projects/:projectID/alerts", h.createAlertRule)
|
||||||
|
api.PUT("/projects/:projectID/alerts/:ruleID", h.updateAlertRule)
|
||||||
|
api.DELETE("/projects/:projectID/alerts/:ruleID", h.deleteAlertRule)
|
||||||
|
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
|
||||||
|
api.GET("/projects/:projectID/realtime/presence", h.realtimePresence)
|
||||||
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)
|
||||||
|
api.POST("/buckets/:bucketID/object-presigns", h.presignObject)
|
||||||
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
|
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
|
||||||
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
|
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
|
||||||
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
|
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
|
||||||
@@ -123,6 +165,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 {
|
||||||
@@ -136,6 +184,19 @@ func (h *HTTPHandler) me(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) actorContext(c *gin.Context) {
|
||||||
|
actor, ok := middleware.RequireActor(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ActorContext(c.Request.Context(), actor)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) bootstrap(c *gin.Context) {
|
func (h *HTTPHandler) bootstrap(c *gin.Context) {
|
||||||
actor, ok := middleware.RequireActor(c)
|
actor, ok := middleware.RequireActor(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -849,6 +910,43 @@ func (h *HTTPHandler) updateObject(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type presignObjectRequest struct {
|
||||||
|
Key string `json:"key" validate:"required"`
|
||||||
|
Op string `json:"op" validate:"omitempty,oneof=download upload"`
|
||||||
|
TTLSeconds int `json:"ttl_seconds" validate:"omitempty,min=60,max=3600"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// presignObject returns a short-lived URL clients call against storage
|
||||||
|
// directly — keeps large transfers off the backend. S3 driver only.
|
||||||
|
func (h *HTTPHandler) presignObject(c *gin.Context) {
|
||||||
|
actor, ok := middleware.RequireActor(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucketID, ok := parseUUIDParam(c, "bucketID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body presignObjectRequest
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Platform.PresignObject(c.Request.Context(), actor, bucketID, strings.TrimSpace(body.Key), body.Op, body.TTLSeconds)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, services.ErrPresignUnsupported) {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "presign_unsupported", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) deleteObject(c *gin.Context) {
|
func (h *HTTPHandler) deleteObject(c *gin.Context) {
|
||||||
actor, ok := middleware.RequireActor(c)
|
actor, ok := middleware.RequireActor(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1033,12 +1131,19 @@ func (h *HTTPHandler) listDocuments(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
actor, _ := middleware.ActorFromContext(c)
|
actor, _ := middleware.ActorFromContext(c)
|
||||||
result, err := h.Platform.ListDocuments(c.Request.Context(), actor, collectionID, limit, offset)
|
var result []db.CoreDocument
|
||||||
if err != nil {
|
var total int64
|
||||||
h.handleError(c, err)
|
var err error
|
||||||
return
|
// PostgREST-style filter/order switches to the dynamic-query path; the
|
||||||
|
// plain list keeps using the sqlc queries it always has.
|
||||||
|
if filter := c.Query("filter"); filter != "" || c.Query("order") != "" {
|
||||||
|
result, total, err = h.Platform.SearchDocuments(c.Request.Context(), actor, collectionID, filter, c.Query("order"), limit, offset)
|
||||||
|
} else {
|
||||||
|
result, err = h.Platform.ListDocuments(c.Request.Context(), actor, collectionID, limit, offset)
|
||||||
|
if err == nil {
|
||||||
|
total, err = h.Platform.CountDocuments(c.Request.Context(), actor, collectionID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
total, err := h.Platform.CountDocuments(c.Request.Context(), actor, collectionID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.handleError(c, err)
|
h.handleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -1175,17 +1280,58 @@ func (h *HTTPHandler) bindAndValidate(c *gin.Context, target any) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if err := h.Validate.Struct(target); err != nil {
|
if err := h.Validate.Struct(target); err != nil {
|
||||||
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", err.Error())
|
var valErrs validator.ValidationErrors
|
||||||
|
if errors.As(err, &valErrs) && len(valErrs) > 0 {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", formatFieldError(valErrs[0]))
|
||||||
|
} else {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", "invalid request body")
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatFieldError(f validator.FieldError) string {
|
||||||
|
if f.Tag() == "required" {
|
||||||
|
return fmt.Sprintf("%s is required", strings.ToLower(f.Field()))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s failed %s validation", strings.ToLower(f.Field()), f.Tag())
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) handleError(c *gin.Context, err error) {
|
func (h *HTTPHandler) handleError(c *gin.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, io.EOF):
|
case errors.Is(err, io.EOF):
|
||||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
|
||||||
|
case errors.Is(err, pgx.ErrNoRows):
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "resource not found")
|
||||||
default:
|
default:
|
||||||
|
var inputErr *services.InputError
|
||||||
|
if errors.As(err, &inputErr) {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", inputErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var valErrs validator.ValidationErrors
|
||||||
|
if errors.As(err, &valErrs) && len(valErrs) > 0 {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", formatFieldError(valErrs[0]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
// Structured codes — never leak raw SQLSTATE text or constraint names.
|
||||||
|
switch pgErr.Code {
|
||||||
|
case "23505":
|
||||||
|
apperrors.Abort(c, http.StatusConflict, "conflict", "resource already exists")
|
||||||
|
case "23503":
|
||||||
|
apperrors.Abort(c, http.StatusConflict, "conflict", "referenced resource does not exist")
|
||||||
|
case "22001", "54000":
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "value too large")
|
||||||
|
case "22021":
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "input is not valid UTF-8")
|
||||||
|
default:
|
||||||
|
apperrors.Abort(c, http.StatusInternalServerError, "request_failed", "database error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
message := strings.ToLower(err.Error())
|
message := strings.ToLower(err.Error())
|
||||||
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
|
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
|
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
@@ -104,4 +109,46 @@ func TestHandleErrorStatusMapping(t *testing.T) {
|
|||||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("maps missing rows to not found", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, recorder := newTestContext("")
|
||||||
|
handler.handleError(ctx, pgx.ErrNoRows)
|
||||||
|
if recorder.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("maps InputError to bad request with message", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, recorder := newTestContext("")
|
||||||
|
handler.handleError(ctx, services.NewInputError("webhook url must be https"))
|
||||||
|
if recorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "webhook url must be https") {
|
||||||
|
t.Fatalf("expected reason in body, got %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("maps unique violation to conflict without leaking", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, recorder := newTestContext("")
|
||||||
|
handler.handleError(ctx, &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint \"projects_slug_key\""})
|
||||||
|
if recorder.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(recorder.Body.String(), "projects_slug_key") || strings.Contains(recorder.Body.String(), "23505") {
|
||||||
|
t.Fatalf("raw constraint leaked: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("maps unknown pg error to internal error", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, recorder := newTestContext("")
|
||||||
|
handler.handleError(ctx, &pgconn.PgError{Code: "XX000", Message: "internal"})
|
||||||
|
if recorder.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxInboundBody = 256 * 1024 // external POSTs capped at 256 KiB
|
||||||
|
|
||||||
|
func (h *HTTPHandler) inboundError(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "hook not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
switch {
|
||||||
|
case strings.Contains(msg, "invalid signature"):
|
||||||
|
apperrors.Abort(c, http.StatusUnauthorized, "unauthorized", msg)
|
||||||
|
case strings.Contains(msg, "invalid") || strings.Contains(msg, "already exists") || strings.Contains(msg, "disabled") || strings.Contains(msg, "requires") || strings.Contains(msg, "not found in this project"):
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||||
|
default:
|
||||||
|
h.handleError(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listInboundHooks(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListInboundHooks(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.inboundError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) createInboundHook(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.CreateInboundHookInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.CreateInboundHook(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.inboundError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteInboundHook(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hookID, err := uuid.Parse(c.Param("hookID"))
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid hook id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteInboundHook(c.Request.Context(), actor, projectID, hookID, middleware.RequestIDFromContext(c)); err != nil {
|
||||||
|
h.inboundError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listEmailLog(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var limit int64
|
||||||
|
if raw := c.Query("limit"); raw != "" {
|
||||||
|
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListEmailLog(c.Request.Context(), actor, projectID, limit)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// receiveInboundHook is the public ingest endpoint — the URL token is the
|
||||||
|
// credential, so no actor is required.
|
||||||
|
func (h *HTTPHandler) receiveInboundHook(c *gin.Context) {
|
||||||
|
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxInboundBody))
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusRequestEntityTooLarge, "too_large", "body exceeds 256 KiB")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ReceiveInboundHook(c.Request.Context(), c.Param("token"), c.GetHeader("X-Primora-Signature"), body)
|
||||||
|
if err != nil {
|
||||||
|
h.inboundError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// instancePublic serves unauthenticated bootstrap state to the login page.
|
||||||
|
// It intentionally exposes only what the sign-in UI needs.
|
||||||
|
func (h *HTTPHandler) instancePublic(c *gin.Context) {
|
||||||
|
users, err := h.Settings.AuthUserCount(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signupEnabled := false
|
||||||
|
if users > 0 {
|
||||||
|
signupEnabled, err = h.Settings.GetBool(c.Request.Context(), "auth.signup_enabled")
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"signup_enabled": signupEnabled,
|
||||||
|
"bootstrap_required": users == 0,
|
||||||
|
"social_providers": managedSocialProviders(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// managedSocialProviders mirrors the auth service's managed gate: OAuth only
|
||||||
|
// exists when PRIMORA_MANAGED=true and the provider's client id is set.
|
||||||
|
func managedSocialProviders() []string {
|
||||||
|
if os.Getenv("PRIMORA_MANAGED") != "true" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
providers := []string{}
|
||||||
|
for _, p := range []struct{ name, envKey string }{
|
||||||
|
{"github", "GITHUB_CLIENT_ID"},
|
||||||
|
{"google", "GOOGLE_CLIENT_ID"},
|
||||||
|
{"discord", "DISCORD_CLIENT_ID"},
|
||||||
|
{"microsoft", "MICROSOFT_CLIENT_ID"},
|
||||||
|
} {
|
||||||
|
if os.Getenv(p.envKey) != "" {
|
||||||
|
providers = append(providers, p.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return providers
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireInstanceAdmin gates handlers on the platform admin role (better-auth
|
||||||
|
// `user.role`), which is distinct from org/project roles.
|
||||||
|
func (h *HTTPHandler) requireInstanceAdmin(c *gin.Context) bool {
|
||||||
|
actor, ok := middleware.RequireActor(c)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !actor.IsUser() || !h.Settings.IsAdmin(c.Request.Context(), actor.AuthSubject) {
|
||||||
|
apperrors.Abort(c, http.StatusForbidden, "admin_required", "instance admin role required")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listInstanceSettings(c *gin.Context) {
|
||||||
|
if !h.requireInstanceAdmin(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, err := h.Settings.ListResolved(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) updateInstanceSetting(c *gin.Context) {
|
||||||
|
if !h.requireInstanceAdmin(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := c.Param("key")
|
||||||
|
if _, ok := services.SettingSpecs[key]; !ok {
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "unknown_setting", "unknown setting key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Value json.RawMessage `json:"value" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Value) == 0 {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "expected {\"value\": ...}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor, _ := middleware.ActorFromContext(c)
|
||||||
|
if err := h.Settings.Store(c.Request.Context(), key, body.Value, actor.Email); err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_value", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, source, err := h.Settings.Resolve(c.Request.Context(), key)
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spec := services.SettingSpecs[key]
|
||||||
|
resp := gin.H{"key": key, "type": spec.Type, "secret": spec.Secret, "is_set": true, "source": source}
|
||||||
|
if !spec.Secret {
|
||||||
|
resp["value"] = value
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteInstanceSetting(c *gin.Context) {
|
||||||
|
if !h.requireInstanceAdmin(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := c.Param("key")
|
||||||
|
if _, ok := services.SettingSpecs[key]; !ok {
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "unknown_setting", "unknown setting key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Settings.Delete(c.Request.Context(), key); err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *HTTPHandler) secretError(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "secret not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "invalid secret name") {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "encryption is not configured") {
|
||||||
|
apperrors.Abort(c, http.StatusServiceUnavailable, "unavailable", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listProjectSecrets(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListProjectSecrets(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.secretError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) setProjectSecret(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.SetProjectSecretInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.Name = c.Param("name")
|
||||||
|
result, err := h.Platform.SetProjectSecret(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.secretError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteProjectSecret(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c)); err != nil {
|
||||||
|
h.secretError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) revealProjectSecret(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.RevealProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.secretError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ type AuthMiddleware struct {
|
|||||||
Redis *redis.Client
|
Redis *redis.Client
|
||||||
Verifier *auth.Verifier
|
Verifier *auth.Verifier
|
||||||
RateLimits RateLimitConfig
|
RateLimits RateLimitConfig
|
||||||
|
// RateLimitResolver overrides RateLimits with live instance settings when
|
||||||
|
// set — it resolves "user" and "api_key" limits per request (cached).
|
||||||
|
RateLimitResolver func(ctx context.Context, scope string) int
|
||||||
}
|
}
|
||||||
|
|
||||||
type RateLimitConfig struct {
|
type RateLimitConfig struct {
|
||||||
@@ -44,6 +47,16 @@ type RateLimitConfig struct {
|
|||||||
UserPerMinute int
|
UserPerMinute int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m AuthMiddleware) rateLimit(ctx context.Context, scope string, fallback int) int {
|
||||||
|
if m.RateLimitResolver == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if v := m.RateLimitResolver(ctx, scope); v >= 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
func RequestID() gin.HandlerFunc {
|
func RequestID() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
requestID := c.Request.Header.Get("X-Request-ID")
|
requestID := c.Request.Header.Get("X-Request-ID")
|
||||||
@@ -78,9 +91,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 +109,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())
|
||||||
@@ -104,7 +118,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
|||||||
if actor.UserID != nil {
|
if actor.UserID != nil {
|
||||||
userIdentity = actor.UserID.String()
|
userIdentity = actor.UserID.String()
|
||||||
}
|
}
|
||||||
if !m.enforceRateLimit(c, "user", userIdentity, m.RateLimits.UserPerMinute, "User rate limit exceeded") {
|
if !m.enforceRateLimit(c, "user", userIdentity, m.rateLimit(c.Request.Context(), "user", m.RateLimits.UserPerMinute), "User rate limit exceeded") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Set(actorKey, actor)
|
c.Set(actorKey, actor)
|
||||||
@@ -122,7 +136,11 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
|||||||
apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error())
|
apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.RateLimits.APIKeyPerMinute, "API key rate limit exceeded") {
|
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.rateLimit(c.Request.Context(), "api_key", m.RateLimits.APIKeyPerMinute), "API key rate limit exceeded") {
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
c.Set(actorKey, actor)
|
c.Set(actorKey, actor)
|
||||||
@@ -264,9 +282,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())
|
||||||
|
}
|
||||||
@@ -27,6 +27,12 @@ func (r *CoreRepository) Queries() *db.Queries {
|
|||||||
return r.queries
|
return r.queries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pool exposes the pgx pool for dynamic queries sqlc can't express
|
||||||
|
// (PostgREST-style document filtering builds its WHERE clause at runtime).
|
||||||
|
func (r *CoreRepository) Pool() *pgxpool.Pool {
|
||||||
|
return r.pool
|
||||||
|
}
|
||||||
|
|
||||||
func (r *CoreRepository) WithTx(ctx context.Context, fn func(*db.Queries) error) error {
|
func (r *CoreRepository) WithTx(ctx context.Context, fn func(*db.Queries) error) error {
|
||||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,3 +62,35 @@ func (r *CoreRepository) GetAPIKeyByPrefix(ctx context.Context, prefix string) (
|
|||||||
func (r *CoreRepository) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
|
func (r *CoreRepository) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
|
||||||
return r.queries.TouchAPIKey(ctx, id)
|
return r.queries.TouchAPIKey(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth-user lookups hit the better-auth tables in the public schema. They are
|
||||||
|
// raw queries on purpose: sqlc only knows the core schema, and a view would
|
||||||
|
// break when the backend migrates before the auth service has created its
|
||||||
|
// tables.
|
||||||
|
func (r *CoreRepository) GetAuthUserRole(ctx context.Context, authSubject string) (string, error) {
|
||||||
|
var role string
|
||||||
|
err := r.pool.QueryRow(ctx, `select coalesce(role, 'user') from public."user" where id = $1`, authSubject).Scan(&role)
|
||||||
|
return role, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CoreRepository) CountAuthUsers(ctx context.Context) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := r.pool.QueryRow(ctx, `select count(*) from public."user"`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CoreRepository) GetSetting(ctx context.Context, key string) (db.GetSettingRow, error) {
|
||||||
|
return r.queries.GetSetting(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CoreRepository) ListSettings(ctx context.Context) ([]db.ListSettingsRow, error) {
|
||||||
|
return r.queries.ListSettings(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CoreRepository) UpsertSetting(ctx context.Context, params db.UpsertSettingParams) (db.CoreSetting, error) {
|
||||||
|
return r.queries.UpsertSetting(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CoreRepository) DeleteSetting(ctx context.Context, key string) error {
|
||||||
|
return r.queries.DeleteSetting(ctx, key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Alert rules detect conditions in telemetry (component heartbeat silence,
|
||||||
|
// error-rate spikes) and fire `alert.fired` / `alert.resolved` domain events.
|
||||||
|
// Delivery reuses the webhook dispatcher + realtime stream — the evaluator is
|
||||||
|
// the only new machinery. Rules fire on state transitions only, so a flaky
|
||||||
|
// component produces one fired + one resolved, not a storm.
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlertEventFired = "alert.fired"
|
||||||
|
AlertEventResolved = "alert.resolved"
|
||||||
|
|
||||||
|
AlertKindHeartbeatSilence = "heartbeat_silence"
|
||||||
|
AlertKindErrorSpike = "error_spike"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlertRuleConfig is the JSONB config per rule kind.
|
||||||
|
//
|
||||||
|
// heartbeat_silence: component beats less often than ThresholdMinutes.
|
||||||
|
// error_spike: component logs more than ThresholdCount errors in
|
||||||
|
// WindowMinutes.
|
||||||
|
// Component empty = evaluate every component in the project.
|
||||||
|
type AlertRuleConfig struct {
|
||||||
|
Component string `json:"component,omitempty"`
|
||||||
|
ThresholdMinutes int `json:"threshold_minutes,omitempty"`
|
||||||
|
ThresholdCount int64 `json:"threshold_count,omitempty"`
|
||||||
|
WindowMinutes int `json:"window_minutes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertRuleSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Config AlertRuleConfig `json:"config"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Firing []string `json:"firing"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpsertAlertRuleInput struct {
|
||||||
|
Name string `json:"name" validate:"required"`
|
||||||
|
Kind string `json:"kind" validate:"required"`
|
||||||
|
Config AlertRuleConfig `json:"config"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) validateAlertRule(input UpsertAlertRuleInput) (AlertRuleConfig, error) {
|
||||||
|
if !projectSecretNameRe.MatchString(input.Name) {
|
||||||
|
return AlertRuleConfig{}, fmt.Errorf("invalid alert rule name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name)
|
||||||
|
}
|
||||||
|
cfg := input.Config
|
||||||
|
switch input.Kind {
|
||||||
|
case AlertKindHeartbeatSilence:
|
||||||
|
if cfg.ThresholdMinutes < 1 || cfg.ThresholdMinutes > 10080 {
|
||||||
|
return cfg, errors.New("threshold_minutes must be 1–10080")
|
||||||
|
}
|
||||||
|
case AlertKindErrorSpike:
|
||||||
|
if cfg.ThresholdCount < 1 {
|
||||||
|
return cfg, errors.New("threshold_count must be ≥ 1")
|
||||||
|
}
|
||||||
|
if cfg.WindowMinutes == 0 {
|
||||||
|
cfg.WindowMinutes = 15
|
||||||
|
}
|
||||||
|
if cfg.WindowMinutes < 1 || cfg.WindowMinutes > 1440 {
|
||||||
|
return cfg, errors.New("window_minutes must be 1–1440")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return cfg, fmt.Errorf("invalid kind %q — %s or %s", input.Kind, AlertKindHeartbeatSilence, AlertKindErrorSpike)
|
||||||
|
}
|
||||||
|
if cfg.Component != "" && len(cfg.Component) > 128 {
|
||||||
|
return cfg, errors.New("component name too long")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertRuleSummary(row db.CoreAlertRule) AlertRuleSummary {
|
||||||
|
var cfg AlertRuleConfig
|
||||||
|
_ = json.Unmarshal(row.Config, &cfg)
|
||||||
|
firing := parseFiring(row.State)
|
||||||
|
return AlertRuleSummary{
|
||||||
|
ID: row.ID.String(),
|
||||||
|
ProjectID: row.ProjectID.String(),
|
||||||
|
Name: row.Name,
|
||||||
|
Kind: row.Kind,
|
||||||
|
Config: cfg,
|
||||||
|
Enabled: row.Enabled,
|
||||||
|
Firing: firing.list(),
|
||||||
|
CreatedAt: row.CreatedAt.Time,
|
||||||
|
UpdatedAt: row.UpdatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListAlertRules(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]AlertRuleSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListAlertRules(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]AlertRuleSummary, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = alertRuleSummary(r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) CreateAlertRule(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input UpsertAlertRuleInput, requestID string) (AlertRuleSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return AlertRuleSummary{}, err
|
||||||
|
}
|
||||||
|
cfg, err := s.validateAlertRule(input)
|
||||||
|
if err != nil {
|
||||||
|
return AlertRuleSummary{}, err
|
||||||
|
}
|
||||||
|
rawCfg, _ := json.Marshal(cfg)
|
||||||
|
row, err := s.repo.Queries().CreateAlertRule(ctx, db.CreateAlertRuleParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Name: input.Name,
|
||||||
|
Kind: input.Kind,
|
||||||
|
Config: rawCfg,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "alert_rules_project_id_name_key") {
|
||||||
|
return AlertRuleSummary{}, fmt.Errorf("an alert rule named %s already exists", input.Name)
|
||||||
|
}
|
||||||
|
return AlertRuleSummary{}, err
|
||||||
|
}
|
||||||
|
s.auditAlert(ctx, actor, projectID, requestID, "alert.created", row.ID, input.Name)
|
||||||
|
return alertRuleSummary(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) UpdateAlertRule(ctx context.Context, actor *models.Actor, projectID, ruleID uuid.UUID, input UpsertAlertRuleInput, requestID string) (AlertRuleSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return AlertRuleSummary{}, err
|
||||||
|
}
|
||||||
|
if _, err := s.repo.Queries().GetAlertRule(ctx, db.GetAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil {
|
||||||
|
return AlertRuleSummary{}, err // pgx.ErrNoRows → 404 at the handler
|
||||||
|
}
|
||||||
|
cfg, err := s.validateAlertRule(input)
|
||||||
|
if err != nil {
|
||||||
|
return AlertRuleSummary{}, err
|
||||||
|
}
|
||||||
|
enabled := true
|
||||||
|
if input.Enabled != nil {
|
||||||
|
enabled = *input.Enabled
|
||||||
|
}
|
||||||
|
rawCfg, _ := json.Marshal(cfg)
|
||||||
|
row, err := s.repo.Queries().UpdateAlertRule(ctx, db.UpdateAlertRuleParams{
|
||||||
|
ID: ruleID, ProjectID: projectID, Name: input.Name, Config: rawCfg, Enabled: enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return AlertRuleSummary{}, err
|
||||||
|
}
|
||||||
|
s.auditAlert(ctx, actor, projectID, requestID, "alert.updated", row.ID, input.Name)
|
||||||
|
return alertRuleSummary(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) DeleteAlertRule(ctx context.Context, actor *models.Actor, projectID, ruleID uuid.UUID, requestID string) error {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.repo.Queries().GetAlertRule(ctx, db.GetAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.repo.Queries().DeleteAlertRule(ctx, db.DeleteAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.auditAlert(ctx, actor, projectID, requestID, "alert.deleted", ruleID, "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) auditAlert(ctx context.Context, actor *models.Actor, projectID uuid.UUID, requestID, action string, ruleID uuid.UUID, name string) {
|
||||||
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, action, "alert_rule", ruleID.String(), map[string]any{"name": name}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// evaluator — one ticker per backend, transition-based firing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type firingSet map[string]bool
|
||||||
|
|
||||||
|
func parseFiring(raw []byte) firingSet {
|
||||||
|
var state struct {
|
||||||
|
Firing map[string]bool `json:"firing"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &state); err != nil || state.Firing == nil {
|
||||||
|
return firingSet{}
|
||||||
|
}
|
||||||
|
return state.Firing
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f firingSet) list() []string {
|
||||||
|
out := make([]string, 0, len(f))
|
||||||
|
for name, on := range f {
|
||||||
|
if on {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertQueries is the slice of db.Queries the evaluator needs — narrow so
|
||||||
|
// tests can stub it without a database.
|
||||||
|
type alertQueries interface {
|
||||||
|
ListEnabledAlertRules(ctx context.Context) ([]db.CoreAlertRule, error)
|
||||||
|
LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]db.LastHeartbeatsRow, error)
|
||||||
|
RecentErrorCounts(ctx context.Context, arg db.RecentErrorCountsParams) ([]db.RecentErrorCountsRow, error)
|
||||||
|
SetAlertRuleState(ctx context.Context, arg db.SetAlertRuleStateParams) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type alertEvaluator struct {
|
||||||
|
q alertQueries
|
||||||
|
publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
|
||||||
|
logger *slog.Logger
|
||||||
|
tick time.Duration
|
||||||
|
quit chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
started sync.Once
|
||||||
|
leader *leaderState
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAlertEvaluator(q alertQueries, publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), logger *slog.Logger, pool *pgxpool.Pool) *alertEvaluator {
|
||||||
|
return &alertEvaluator{q: q, publish: publish, logger: logger, tick: time.Minute, quit: make(chan struct{}), leader: newLeaderState(pool, leaderKeyAlerts)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *alertEvaluator) Start(ctx context.Context) {
|
||||||
|
e.started.Do(func() {
|
||||||
|
e.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer e.wg.Done()
|
||||||
|
t := time.NewTicker(e.tick)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
if e.leader.hold(ctx) {
|
||||||
|
e.scan(ctx)
|
||||||
|
}
|
||||||
|
case <-e.quit:
|
||||||
|
e.leader.stop(context.Background())
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *alertEvaluator) Stop() {
|
||||||
|
close(e.quit)
|
||||||
|
e.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *alertEvaluator) scan(ctx context.Context) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
rules, err := e.q.ListEnabledAlertRules(ctx)
|
||||||
|
if err != nil {
|
||||||
|
e.logger.Warn("alert scan: list rules", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, rule := range rules {
|
||||||
|
if err := e.evaluate(ctx, rule); err != nil {
|
||||||
|
e.logger.Warn("alert scan: evaluate", "rule", rule.Name, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *alertEvaluator) evaluate(ctx context.Context, rule db.CoreAlertRule) error {
|
||||||
|
var cfg AlertRuleConfig
|
||||||
|
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
|
||||||
|
return nil // bad config can't be fixed by re-scanning; skip quietly
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
var breached map[string]float64 // component → measured value
|
||||||
|
var metric, unit string
|
||||||
|
|
||||||
|
switch rule.Kind {
|
||||||
|
case AlertKindHeartbeatSilence:
|
||||||
|
rows, err := e.q.LastHeartbeats(ctx, rule.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
breached = map[string]float64{}
|
||||||
|
for _, r := range rows {
|
||||||
|
silent := now.Sub(r.LastBeat.Time).Minutes()
|
||||||
|
if silent >= float64(cfg.ThresholdMinutes) {
|
||||||
|
breached[r.ComponentName] = silent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metric, unit = "silence", "minutes"
|
||||||
|
|
||||||
|
case AlertKindErrorSpike:
|
||||||
|
rows, err := e.q.RecentErrorCounts(ctx, db.RecentErrorCountsParams{
|
||||||
|
ProjectID: rule.ProjectID,
|
||||||
|
Column2: int64(cfg.WindowMinutes) * 60,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
breached = map[string]float64{}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Errors >= cfg.ThresholdCount {
|
||||||
|
breached[r.ComponentName] = float64(r.Errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metric, unit = "errors", "count"
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
firing := parseFiring(rule.State)
|
||||||
|
changed := false
|
||||||
|
// newly breached components → fired
|
||||||
|
for comp, value := range breached {
|
||||||
|
if cfg.Component != "" && comp != cfg.Component {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if firing[comp] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
firing[comp] = true
|
||||||
|
changed = true
|
||||||
|
e.publish(ctx, rule.ProjectID, AlertEventFired, map[string]any{
|
||||||
|
"rule_id": rule.ID.String(), "rule": rule.Name, "kind": rule.Kind,
|
||||||
|
"component": comp, "metric": metric, "value": value, "unit": unit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// recovered components → resolved
|
||||||
|
for comp, on := range firing {
|
||||||
|
_, stillBreached := breached[comp]
|
||||||
|
if !on || stillBreached {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
firing[comp] = false
|
||||||
|
changed = true
|
||||||
|
e.publish(ctx, rule.ProjectID, AlertEventResolved, map[string]any{
|
||||||
|
"rule_id": rule.ID.String(), "rule": rule.Name, "kind": rule.Kind,
|
||||||
|
"component": comp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(map[string]any{"firing": firing})
|
||||||
|
return e.q.SetAlertRuleState(ctx, db.SetAlertRuleStateParams{ID: rule.ID, ProjectID: rule.ProjectID, State: raw})
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubAlertQueries struct {
|
||||||
|
rules []db.CoreAlertRule
|
||||||
|
beats []db.LastHeartbeatsRow
|
||||||
|
errors []db.RecentErrorCountsRow
|
||||||
|
stateSaved map[uuid.UUID][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubAlertQueries) ListEnabledAlertRules(ctx context.Context) ([]db.CoreAlertRule, error) {
|
||||||
|
return s.rules, nil
|
||||||
|
}
|
||||||
|
func (s *stubAlertQueries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]db.LastHeartbeatsRow, error) {
|
||||||
|
return s.beats, nil
|
||||||
|
}
|
||||||
|
func (s *stubAlertQueries) RecentErrorCounts(ctx context.Context, arg db.RecentErrorCountsParams) ([]db.RecentErrorCountsRow, error) {
|
||||||
|
return s.errors, nil
|
||||||
|
}
|
||||||
|
func (s *stubAlertQueries) SetAlertRuleState(ctx context.Context, arg db.SetAlertRuleStateParams) error {
|
||||||
|
if s.stateSaved == nil {
|
||||||
|
s.stateSaved = map[uuid.UUID][]byte{}
|
||||||
|
}
|
||||||
|
s.stateSaved[arg.ID] = arg.State
|
||||||
|
// persist back so subsequent evaluations see the transition
|
||||||
|
for i, r := range s.rules {
|
||||||
|
if r.ID == arg.ID {
|
||||||
|
s.rules[i].State = arg.State
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rule(t *testing.T, kind string, cfg AlertRuleConfig) db.CoreAlertRule {
|
||||||
|
raw, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db.CoreAlertRule{ID: uuid.New(), ProjectID: uuid.New(), Name: "r1", Kind: kind, Config: raw, State: []byte(`{"firing":{}}`)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collect() (func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), *[]string) {
|
||||||
|
events := &[]string{}
|
||||||
|
return func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
|
||||||
|
*events = append(*events, eventType+":"+data["component"].(string))
|
||||||
|
}, events
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilenceFiresOnceThenResolves(t *testing.T) {
|
||||||
|
r := rule(t, AlertKindHeartbeatSilence, AlertRuleConfig{ThresholdMinutes: 5})
|
||||||
|
stub := &stubAlertQueries{
|
||||||
|
rules: []db.CoreAlertRule{r},
|
||||||
|
beats: []db.LastHeartbeatsRow{{
|
||||||
|
ComponentName: "web",
|
||||||
|
LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-10 * time.Minute), Valid: true},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
publish, events := collect()
|
||||||
|
e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()}
|
||||||
|
|
||||||
|
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(*events) != 1 || (*events)[0] != "alert.fired:web" {
|
||||||
|
t.Fatalf("want one alert.fired:web, got %v", *events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// second evaluation — still silent, must not refire
|
||||||
|
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(*events) != 1 {
|
||||||
|
t.Fatalf("refired: %v", *events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// heartbeat resumes → resolved
|
||||||
|
stub.beats[0].LastBeat = pgtype.Timestamptz{Time: time.Now(), Valid: true}
|
||||||
|
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(*events) != 2 || (*events)[1] != "alert.resolved:web" {
|
||||||
|
t.Fatalf("want alert.resolved:web, got %v", *events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertErrorSpikeThreshold(t *testing.T) {
|
||||||
|
r := rule(t, AlertKindErrorSpike, AlertRuleConfig{ThresholdCount: 3, WindowMinutes: 15})
|
||||||
|
stub := &stubAlertQueries{
|
||||||
|
rules: []db.CoreAlertRule{r},
|
||||||
|
errors: []db.RecentErrorCountsRow{{ComponentName: "api", Errors: 2}},
|
||||||
|
}
|
||||||
|
publish, events := collect()
|
||||||
|
e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()}
|
||||||
|
|
||||||
|
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(*events) != 0 {
|
||||||
|
t.Fatalf("under threshold fired: %v", *events)
|
||||||
|
}
|
||||||
|
|
||||||
|
stub.errors[0].Errors = 5
|
||||||
|
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(*events) != 1 || (*events)[0] != "alert.fired:api" {
|
||||||
|
t.Fatalf("want alert.fired:api, got %v", *events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertComponentFilter(t *testing.T) {
|
||||||
|
r := rule(t, AlertKindHeartbeatSilence, AlertRuleConfig{Component: "web", ThresholdMinutes: 5})
|
||||||
|
stub := &stubAlertQueries{
|
||||||
|
rules: []db.CoreAlertRule{r},
|
||||||
|
beats: []db.LastHeartbeatsRow{
|
||||||
|
{ComponentName: "web", LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}},
|
||||||
|
{ComponentName: "api", LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
publish, events := collect()
|
||||||
|
e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()}
|
||||||
|
|
||||||
|
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(*events) != 1 || (*events)[0] != "alert.fired:web" {
|
||||||
|
t.Fatalf("filtered rule must fire only for web, got %v", *events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAlertRule(t *testing.T) {
|
||||||
|
s := &PlatformService{}
|
||||||
|
if _, err := s.validateAlertRule(UpsertAlertRuleInput{Name: "ok", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 5}}); err != nil {
|
||||||
|
t.Fatalf("valid silence rule rejected: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := s.validateAlertRule(UpsertAlertRuleInput{Name: "ok", Kind: AlertKindErrorSpike, Config: AlertRuleConfig{ThresholdCount: 3}})
|
||||||
|
if err != nil || cfg.WindowMinutes != 15 {
|
||||||
|
t.Fatalf("window default: cfg=%+v err=%v", cfg, err)
|
||||||
|
}
|
||||||
|
for _, bad := range []UpsertAlertRuleInput{
|
||||||
|
{Name: "bad name!", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 5}},
|
||||||
|
{Name: "x", Kind: "bogus", Config: AlertRuleConfig{}},
|
||||||
|
{Name: "x", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 0}},
|
||||||
|
{Name: "x", Kind: AlertKindErrorSpike, Config: AlertRuleConfig{ThresholdCount: 0}},
|
||||||
|
} {
|
||||||
|
if _, err := s.validateAlertRule(bad); err == nil {
|
||||||
|
t.Fatalf("accepted invalid rule %+v", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,218 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostgREST-lite filtering over document JSONB:
|
||||||
|
// ?filter=status.eq.active,age.gt.18,role.in.admin|editor
|
||||||
|
// ?order=name.asc,created_at.desc
|
||||||
|
// Values are always parameterized; only the whitelisted operators and
|
||||||
|
// field-path syntax below ever reach the SQL string.
|
||||||
|
|
||||||
|
var docFieldRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
|
// documentColumns are real table columns — everything else resolves into data.
|
||||||
|
var documentColumns = map[string]string{
|
||||||
|
"id": "id",
|
||||||
|
"created_at": "created_at",
|
||||||
|
"updated_at": "updated_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
// docFieldExpr maps a (possibly dotted) field path to a SQL text expression.
|
||||||
|
// data #>> '{a,b}' extracts nested values as text — the correct comparison
|
||||||
|
// domain for loosely-typed document fields.
|
||||||
|
func docFieldExpr(field string) (string, error) {
|
||||||
|
if col, ok := documentColumns[field]; ok {
|
||||||
|
return col, nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(field, ".")
|
||||||
|
for _, p := range parts {
|
||||||
|
if !docFieldRe.MatchString(p) {
|
||||||
|
return "", fmt.Errorf("invalid field name %q — use [A-Za-z_][A-Za-z0-9_]* segments", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("data #>> '{%s}'", strings.Join(parts, ",")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// numericExpr wraps the field for ordered comparisons — only used when the
|
||||||
|
// filter value itself parses as a float, so the cast can never fail.
|
||||||
|
func numericExpr(expr string) string {
|
||||||
|
return fmt.Sprintf("(%s)::numeric", expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDocumentFilters turns "field.op.value" terms into parameterized SQL.
|
||||||
|
// Returns the WHERE fragments and the argument list (already $N-numbered via
|
||||||
|
// startArg). A fragment is empty for `is` filters since they carry no value.
|
||||||
|
func buildDocumentFilters(filterParam string, startArg int) ([]string, []any, error) {
|
||||||
|
var conds []string
|
||||||
|
var args []any
|
||||||
|
argN := startArg
|
||||||
|
next := func(v any) string {
|
||||||
|
args = append(args, v)
|
||||||
|
argN++
|
||||||
|
return fmt.Sprintf("$%d", argN-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var docOps = []string{"eq", "neq", "gt", "gte", "lt", "lte", "like", "in", "is"}
|
||||||
|
|
||||||
|
for _, term := range strings.Split(filterParam, ",") {
|
||||||
|
term = strings.TrimSpace(term)
|
||||||
|
if term == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Field paths are themselves dotted, so the operator is located by
|
||||||
|
// scanning for the first ".<known-op>." — the value may contain dots.
|
||||||
|
var field, op, value string
|
||||||
|
best := -1
|
||||||
|
for _, candidate := range docOps {
|
||||||
|
needle := "." + candidate + "."
|
||||||
|
if i := strings.Index(term, needle); i > 0 && (best < 0 || i < best) {
|
||||||
|
best = i
|
||||||
|
field, op, value = term[:i], candidate, term[i+len(needle):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if field == "" {
|
||||||
|
return nil, nil, fmt.Errorf("invalid filter %q — expected field.op.value (ops: %s)", term, strings.Join(docOps, ", "))
|
||||||
|
}
|
||||||
|
expr, err := docFieldExpr(field)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
_, isFloat := strconv.ParseFloat(value, 64)
|
||||||
|
|
||||||
|
switch op {
|
||||||
|
case "eq", "neq":
|
||||||
|
sym := "="
|
||||||
|
if op == "neq" {
|
||||||
|
sym = "<>"
|
||||||
|
}
|
||||||
|
conds = append(conds, fmt.Sprintf("%s %s %s", expr, sym, next(value)))
|
||||||
|
case "gt", "gte", "lt", "lte":
|
||||||
|
if isFloat != nil {
|
||||||
|
return nil, nil, fmt.Errorf("filter %q: %s requires a numeric value", term, op)
|
||||||
|
}
|
||||||
|
sym := map[string]string{"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op]
|
||||||
|
f, _ := strconv.ParseFloat(value, 64)
|
||||||
|
conds = append(conds, fmt.Sprintf("%s %s %s", numericExpr(expr), sym, next(f)))
|
||||||
|
case "like":
|
||||||
|
conds = append(conds, fmt.Sprintf("%s ILIKE %s", expr, next(value)))
|
||||||
|
case "in":
|
||||||
|
items := strings.Split(value, "|")
|
||||||
|
for i, item := range items {
|
||||||
|
items[i] = strings.TrimSpace(item)
|
||||||
|
}
|
||||||
|
conds = append(conds, fmt.Sprintf("%s = ANY(%s)", expr, next(items)))
|
||||||
|
case "is":
|
||||||
|
switch value {
|
||||||
|
case "null":
|
||||||
|
conds = append(conds, fmt.Sprintf("%s IS NULL", expr))
|
||||||
|
case "true", "false":
|
||||||
|
conds = append(conds, fmt.Sprintf("%s = %s", expr, next(value)))
|
||||||
|
default:
|
||||||
|
return nil, nil, fmt.Errorf("filter %q: is accepts null|true|false", term)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, nil, fmt.Errorf("unknown operator %q in filter %q — supported: eq, neq, gt, gte, lt, lte, like, in, is", op, term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conds, args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDocumentOrder turns "name.asc,created_at.desc" into an ORDER BY list.
|
||||||
|
func buildDocumentOrder(orderParam string) (string, error) {
|
||||||
|
if orderParam == "" {
|
||||||
|
return "created_at DESC", nil
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for _, term := range strings.Split(orderParam, ",") {
|
||||||
|
term = strings.TrimSpace(term)
|
||||||
|
if term == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Direction is a suffix, not a split — field paths are dotted too.
|
||||||
|
field, dir := term, "asc"
|
||||||
|
if strings.HasSuffix(term, ".desc") {
|
||||||
|
field, dir = strings.TrimSuffix(term, ".desc"), "desc"
|
||||||
|
} else if strings.HasSuffix(term, ".asc") {
|
||||||
|
field = strings.TrimSuffix(term, ".asc")
|
||||||
|
}
|
||||||
|
expr, err := docFieldExpr(field)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
parts = append(parts, expr+" "+strings.ToUpper(dir))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "created_at DESC", nil
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchDocuments lists documents with PostgREST-style filter/order params.
|
||||||
|
// Returns the page plus the un-paginated match count for "total".
|
||||||
|
func (s *PlatformService) SearchDocuments(
|
||||||
|
ctx context.Context,
|
||||||
|
actor *models.Actor,
|
||||||
|
collectionID uuid.UUID,
|
||||||
|
filterParam, orderParam string,
|
||||||
|
limit, offset int32,
|
||||||
|
) ([]db.CoreDocument, int64, error) {
|
||||||
|
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []any{collectionID}
|
||||||
|
conds, filterArgs, err := buildDocumentFilters(filterParam, len(args)+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
args = append(args, filterArgs...)
|
||||||
|
|
||||||
|
where := "collection_id = $1"
|
||||||
|
if len(conds) > 0 {
|
||||||
|
where += " AND " + strings.Join(conds, " AND ")
|
||||||
|
}
|
||||||
|
order, err := buildDocumentOrder(orderParam)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := s.repo.Pool().QueryRow(ctx,
|
||||||
|
fmt.Sprintf("SELECT COUNT(*) FROM core.documents WHERE %s", where), args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
rows, err := s.repo.Pool().Query(ctx, fmt.Sprintf(
|
||||||
|
"SELECT * FROM core.documents WHERE %s ORDER BY %s LIMIT $%d OFFSET $%d",
|
||||||
|
where, order, len(args)-1, len(args)), args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []db.CoreDocument
|
||||||
|
for rows.Next() {
|
||||||
|
var d db.CoreDocument
|
||||||
|
if err := rows.Scan(&d.ID, &d.CollectionID, &d.Data, &d.CreatedByUserID, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildDocumentFilters(t *testing.T) {
|
||||||
|
t.Run("eq on a nested path uses #>>", func(t *testing.T) {
|
||||||
|
conds, args, err := buildDocumentFilters("meta.city.eq.Prague", 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
want := "data #>> '{meta,city}' = $2"
|
||||||
|
if conds[0] != want || !reflect.DeepEqual(args, []any{"Prague"}) {
|
||||||
|
t.Fatalf("got %v %v", conds, args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("numeric ops cast to ::numeric", func(t *testing.T) {
|
||||||
|
conds, args, err := buildDocumentFilters("age.gt.18,age.lt.65", 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(conds[0], "::numeric > $2") || !strings.Contains(conds[1], "::numeric < $3") {
|
||||||
|
t.Fatalf("got %v", conds)
|
||||||
|
}
|
||||||
|
if args[0] != 18.0 || args[1] != 65.0 {
|
||||||
|
t.Fatalf("args: %v", args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("numeric op rejects non-numeric value", func(t *testing.T) {
|
||||||
|
_, _, err := buildDocumentFilters("age.gt.abc", 2)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("like becomes ILIKE", func(t *testing.T) {
|
||||||
|
conds, _, err := buildDocumentFilters("name.like.%_smith%", 2)
|
||||||
|
if err != nil || conds[0] != "data #>> '{name}' ILIKE $2" {
|
||||||
|
t.Fatalf("got %v %v", conds, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("in becomes ANY with a text array", func(t *testing.T) {
|
||||||
|
conds, args, err := buildDocumentFilters("role.in.admin|editor", 2)
|
||||||
|
if err != nil || !strings.Contains(conds[0], "= ANY($2)") {
|
||||||
|
t.Fatalf("got %v", conds)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(args[0], []string{"admin", "editor"}) {
|
||||||
|
t.Fatalf("args: %v", args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("is null carries no argument", func(t *testing.T) {
|
||||||
|
conds, args, err := buildDocumentFilters("deleted_at.is.null", 2)
|
||||||
|
if err != nil || len(args) != 0 || conds[0] != "data #>> '{deleted_at}' IS NULL" {
|
||||||
|
t.Fatalf("got %v %v", conds, args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects injection-shaped field", func(t *testing.T) {
|
||||||
|
_, _, err := buildDocumentFilters("x' OR '1'='1.eq.a", 2)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects term without a known operator", func(t *testing.T) {
|
||||||
|
_, _, err := buildDocumentFilters("age.between.5", 2)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid filter") {
|
||||||
|
t.Fatalf("got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("malformed term errors", func(t *testing.T) {
|
||||||
|
_, _, err := buildDocumentFilters("justafield", 2)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("real columns resolve directly", func(t *testing.T) {
|
||||||
|
conds, _, err := buildDocumentFilters("created_at.gt.2026-01-01", 2)
|
||||||
|
// created_at is a column — but value isn't numeric so gt must fail
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected numeric-required error, got %v", conds)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDocumentOrder(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "created_at DESC",
|
||||||
|
"name.asc": "data #>> '{name}' ASC",
|
||||||
|
"name": "data #>> '{name}' ASC",
|
||||||
|
"created_at.desc": "created_at DESC",
|
||||||
|
"a.b.asc,id.desc": "data #>> '{a,b}' ASC, id DESC",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
got, err := buildDocumentOrder(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%q: %v", in, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%q: got %q want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A non-asc/desc suffix is a nested field path, not a direction error.
|
||||||
|
if got, err := buildDocumentOrder("name.sideways"); err != nil || got != "data #>> '{name,sideways}' ASC" {
|
||||||
|
t.Errorf("nested order path: got %q err %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := buildDocumentOrder("x;drop.asc"); err == nil {
|
||||||
|
t.Error("expected field error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCustomEventTypeRe(t *testing.T) {
|
||||||
|
good := []string{"custom.ping", "custom.deploy.done", "custom.a", "custom.x-y_z.9"}
|
||||||
|
bad := []string{
|
||||||
|
"", "custom", "custom.", "document.created", "CUSTOM.ping",
|
||||||
|
"custom.Ping", "custom..x", "custom.-x", "custom." + string(rune(0x20)) + "x",
|
||||||
|
"custom." + string(make([]byte, 64)),
|
||||||
|
}
|
||||||
|
for _, s := range good {
|
||||||
|
if !customEventTypeRe.MatchString(s) {
|
||||||
|
t.Errorf("expected %q to match", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range bad {
|
||||||
|
if customEventTypeRe.MatchString(s) {
|
||||||
|
t.Errorf("expected %q to be rejected", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dockerRunner executes functions in an ephemeral, locked-down container:
|
||||||
|
// no network, read-only rootfs, memory/CPU/pid caps, non-root, all
|
||||||
|
// capabilities dropped. The source is passed through the environment as
|
||||||
|
// base64 and materialized inside the container's tmpfs — no bind mount,
|
||||||
|
// so this works on Docker Desktop (VM daemon) and remote daemons where
|
||||||
|
// host paths aren't shared. The payload arrives on stdin — same contract
|
||||||
|
// as execRunner, but user code no longer inherits backend privileges.
|
||||||
|
// Requires the Docker socket (compose: mount /var/run/docker.sock) — the
|
||||||
|
// documented trade-off: socket access is root-equivalent on the host, so
|
||||||
|
// this driver isolates function code, not operators.
|
||||||
|
type dockerRunner struct {
|
||||||
|
timeout time.Duration
|
||||||
|
maxOutput int64
|
||||||
|
images map[string]string // runtime -> image ref
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxFnSourceBytes caps function source for the docker driver — the source
|
||||||
|
// travels in the container spec (env), which kernels limit to ~2 MB total;
|
||||||
|
// 256 KiB keeps argv+env comfortably inside it.
|
||||||
|
const maxFnSourceBytes = 256 << 10
|
||||||
|
|
||||||
|
func NewDockerRunner(timeout time.Duration, maxOutput int64) *dockerRunner {
|
||||||
|
return &dockerRunner{
|
||||||
|
timeout: timeout,
|
||||||
|
maxOutput: maxOutput,
|
||||||
|
images: map[string]string{
|
||||||
|
"bun": "oven/bun:1.3.14",
|
||||||
|
"deno": "denoland/deno:2.5.6",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *dockerRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult {
|
||||||
|
image, ok := r.images[runtime]
|
||||||
|
if !ok {
|
||||||
|
return FunctionResult{Status: "error", Stderr: "no container image configured for runtime " + runtime}
|
||||||
|
}
|
||||||
|
if len(code) > maxFnSourceBytes {
|
||||||
|
return FunctionResult{Status: "error", Stderr: fmt.Sprintf("function source exceeds %d KiB docker-driver limit", maxFnSourceBytes>>10)}
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
return FunctionResult{Status: "error", Stderr: "FUNCTIONS_DRIVER=docker but docker CLI not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, r.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Named container so a timed-out client doesn't orphan a running fn.
|
||||||
|
var suffix [4]byte
|
||||||
|
_, _ = rand.Read(suffix[:])
|
||||||
|
name := "primora-fn-" + hex.EncodeToString(suffix[:])
|
||||||
|
|
||||||
|
// Write source inside the container, then run it. base64 keeps the
|
||||||
|
// code inert data — it is decoded by the shell, never interpreted.
|
||||||
|
entrypoint := fmt.Sprintf(
|
||||||
|
`echo "$PRIMORA_CODE" | base64 -d > /tmp/fn.ts && %s /tmp/fn.ts`,
|
||||||
|
map[bool]string{true: "deno run --no-prompt", false: "bun run"}[runtime == "deno"],
|
||||||
|
)
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"run", "--rm", "-i", "--name", name,
|
||||||
|
"--network", "none",
|
||||||
|
"--memory", "128m", "--memory-swap", "128m",
|
||||||
|
"--cpus", "0.5",
|
||||||
|
"--pids-limit", "64",
|
||||||
|
"--read-only",
|
||||||
|
"--tmpfs", "/tmp:rw,noexec,nosuid,size=16m",
|
||||||
|
"--cap-drop", "ALL",
|
||||||
|
"--security-opt", "no-new-privileges",
|
||||||
|
"--user", "65534:65534",
|
||||||
|
"-e", "PRIMORA_CODE=" + base64.StdEncoding.EncodeToString([]byte(code)),
|
||||||
|
"-e", "PRIMORA_PAYLOAD=" + string(payload),
|
||||||
|
"-e", "PRIMORA_FUNCTION_RUNTIME=" + runtime,
|
||||||
|
"--entrypoint", "sh",
|
||||||
|
image, "-c", entrypoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||||
|
cmd.Stdin = bytes.NewReader(payload)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput}
|
||||||
|
cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput}
|
||||||
|
|
||||||
|
// ctx cancel kills the docker CLI but the daemon keeps the container
|
||||||
|
// running — force-remove it so a stuck function can't linger.
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = exec.Command("docker", "rm", "-f", name).Run()
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
runErr := cmd.Run()
|
||||||
|
duration := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
res := FunctionResult{
|
||||||
|
Status: "success",
|
||||||
|
Stdout: stdout.String(),
|
||||||
|
Stderr: stderr.String(),
|
||||||
|
DurationMs: duration,
|
||||||
|
}
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
res.Status = "timeout"
|
||||||
|
res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out")
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
res.Status = "error"
|
||||||
|
if exitErr, ok := runErr.(*exec.ExitError); ok {
|
||||||
|
code := int32(exitErr.ExitCode())
|
||||||
|
res.ExitCode = &code
|
||||||
|
} else {
|
||||||
|
res.Stderr = strings.TrimSpace(fmt.Sprintf("%v\n%s", runErr, res.Stderr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dockerAvailable(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
t.Skip("docker CLI not installed")
|
||||||
|
}
|
||||||
|
if err := exec.Command("docker", "info").Run(); err != nil {
|
||||||
|
t.Skip("docker daemon not reachable")
|
||||||
|
}
|
||||||
|
// The test pulls the runtime image on first run.
|
||||||
|
if err := exec.Command("docker", "image", "inspect", "oven/bun:1.3.14").Run(); err != nil {
|
||||||
|
t.Skip("oven/bun:1.3.14 image not pulled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerRunnerRunsCode(t *testing.T) {
|
||||||
|
dockerAvailable(t)
|
||||||
|
r := NewDockerRunner(30*time.Second, 65536)
|
||||||
|
res := r.Run(context.Background(), "bun",
|
||||||
|
`const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("hi " + p.name);`,
|
||||||
|
json.RawMessage(`{"name":"sandbox"}`))
|
||||||
|
if res.Status != "success" {
|
||||||
|
t.Fatalf("status = %q stderr = %q", res.Status, res.Stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Stdout, "hi sandbox") {
|
||||||
|
t.Fatalf("stdout = %q", res.Stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerRunnerIsolatesNetwork(t *testing.T) {
|
||||||
|
dockerAvailable(t)
|
||||||
|
r := NewDockerRunner(30*time.Second, 65536)
|
||||||
|
// --network none: even a DNS/connect attempt must fail inside.
|
||||||
|
res := r.Run(context.Background(), "bun",
|
||||||
|
`try { await fetch("http://169.254.169.254/"); console.log("NET-OPEN") } catch { console.log("NET-CLOSED") }`,
|
||||||
|
json.RawMessage(`{}`))
|
||||||
|
if !strings.Contains(res.Stdout, "NET-CLOSED") {
|
||||||
|
t.Fatalf("network should be unreachable, stdout = %q stderr = %q status = %q", res.Stdout, res.Stderr, res.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerRunnerTimesOut(t *testing.T) {
|
||||||
|
dockerAvailable(t)
|
||||||
|
r := NewDockerRunner(2*time.Second, 65536)
|
||||||
|
res := r.Run(context.Background(), "bun", `while (true) {}`, json.RawMessage(`{}`))
|
||||||
|
if res.Status != "timeout" {
|
||||||
|
t.Fatalf("status = %q", res.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var functionNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`)
|
||||||
|
|
||||||
|
// FunctionRunner executes a function's code with a JSON payload on stdin
|
||||||
|
// and returns the outcome. Implemented by execRunner (a runtime binary on
|
||||||
|
// the backend host); tests substitute a fake.
|
||||||
|
type FunctionRunner interface {
|
||||||
|
Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunctionResult is one execution's captured outcome.
|
||||||
|
type FunctionResult struct {
|
||||||
|
Status string // success | error | timeout
|
||||||
|
ExitCode *int32
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
DurationMs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// execRunner shells out to a JS runtime binary. User code runs with the
|
||||||
|
// backend's privileges — this is the documented self-hosted model (same as
|
||||||
|
// n8n custom-code nodes); operators harden at the container/VM boundary.
|
||||||
|
// jarvis: ceiling unsandboxed exec; upgrade to Firecracker/gVisor or a
|
||||||
|
// sidecar runtime pool when multi-tenant isolation is needed.
|
||||||
|
type execRunner struct {
|
||||||
|
bin string
|
||||||
|
timeout time.Duration
|
||||||
|
maxOutput int64
|
||||||
|
resolveBin func(runtime string) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExecRunner(bin string, timeout time.Duration, maxOutput int64) *execRunner {
|
||||||
|
return &execRunner{
|
||||||
|
bin: bin,
|
||||||
|
timeout: timeout,
|
||||||
|
maxOutput: maxOutput,
|
||||||
|
resolveBin: func(runtime string) string {
|
||||||
|
if bin != "" && bin != "auto" {
|
||||||
|
return bin
|
||||||
|
}
|
||||||
|
return runtime // "bun" or "deno" from PATH
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *execRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult {
|
||||||
|
bin := r.resolveBin(runtime)
|
||||||
|
if bin == "" {
|
||||||
|
return FunctionResult{Status: "error", Stderr: "no functions runtime configured"}
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := os.MkdirTemp("", "primora-fn-*")
|
||||||
|
if err != nil {
|
||||||
|
return FunctionResult{Status: "error", Stderr: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
file := filepath.Join(dir, "fn.ts")
|
||||||
|
if err := os.WriteFile(file, []byte(code), 0o600); err != nil {
|
||||||
|
return FunctionResult{Status: "error", Stderr: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, r.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch runtime {
|
||||||
|
case "deno":
|
||||||
|
// --no-prompt: never block on interactive permission prompts.
|
||||||
|
cmd = exec.CommandContext(ctx, bin, "run", "--no-prompt", file)
|
||||||
|
default: // bun
|
||||||
|
cmd = exec.CommandContext(ctx, bin, "run", file)
|
||||||
|
}
|
||||||
|
cmd.Stdin = bytes.NewReader(payload)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"PRIMORA_PAYLOAD="+string(payload),
|
||||||
|
"PRIMORA_FUNCTION_RUNTIME="+runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput}
|
||||||
|
cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
runErr := cmd.Run()
|
||||||
|
duration := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
res := FunctionResult{
|
||||||
|
Status: "success",
|
||||||
|
Stdout: stdout.String(),
|
||||||
|
Stderr: stderr.String(),
|
||||||
|
DurationMs: duration,
|
||||||
|
}
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
res.Status = "timeout"
|
||||||
|
res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out")
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
res.Status = "error"
|
||||||
|
if exitErr, ok := runErr.(*exec.ExitError); ok {
|
||||||
|
code := int32(exitErr.ExitCode())
|
||||||
|
res.ExitCode = &code
|
||||||
|
} else {
|
||||||
|
res.Stderr = strings.TrimSpace(runErr.Error() + "\n" + res.Stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitedWriter caps captured output; excess bytes are discarded, not
|
||||||
|
// stored — a chatty function can't grow function_runs rows unboundedly.
|
||||||
|
type limitedWriter struct {
|
||||||
|
w *bytes.Buffer
|
||||||
|
max int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *limitedWriter) Write(p []byte) (int, error) {
|
||||||
|
remaining := l.max - int64(l.w.Len())
|
||||||
|
if remaining > 0 {
|
||||||
|
if int64(len(p)) > remaining {
|
||||||
|
l.w.Write(p[:remaining])
|
||||||
|
} else {
|
||||||
|
l.w.Write(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- service layer -------------------------------------------------------
|
||||||
|
|
||||||
|
type CreateFunctionInput struct {
|
||||||
|
Name string `json:"name" validate:"required"`
|
||||||
|
Code string `json:"code" validate:"required,max=262144"`
|
||||||
|
Runtime string `json:"runtime" validate:"omitempty,oneof=bun deno"`
|
||||||
|
EventPattern string `json:"event_pattern" validate:"omitempty,max=128"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateFunctionInput struct {
|
||||||
|
Code *string `json:"code" validate:"omitempty,max=262144"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
EventPattern *string `json:"event_pattern" validate:"omitempty,max=128"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunctionSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
EventPattern string `json:"event_pattern,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunctionRunSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode *int32 `json:"exit_code,omitempty"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func functionSummary(r db.CoreFunction) FunctionSummary {
|
||||||
|
return FunctionSummary{
|
||||||
|
ID: r.ID.String(),
|
||||||
|
Name: r.Name,
|
||||||
|
Runtime: r.Runtime,
|
||||||
|
Enabled: r.Enabled,
|
||||||
|
EventPattern: r.EventPattern,
|
||||||
|
CreatedAt: r.CreatedAt.Time,
|
||||||
|
UpdatedAt: r.UpdatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSummary(r db.CoreFunctionRun) FunctionRunSummary {
|
||||||
|
return FunctionRunSummary{
|
||||||
|
ID: r.ID.String(),
|
||||||
|
Trigger: r.Trigger,
|
||||||
|
Status: r.Status,
|
||||||
|
ExitCode: r.ExitCode,
|
||||||
|
Stdout: r.Stdout,
|
||||||
|
Stderr: r.Stderr,
|
||||||
|
DurationMs: int64(r.DurationMs),
|
||||||
|
CreatedAt: r.CreatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) CreateFunction(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateFunctionInput, requestID string) (FunctionSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return FunctionSummary{}, err
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if !functionNamePattern.MatchString(name) {
|
||||||
|
return FunctionSummary{}, fmt.Errorf("invalid function name")
|
||||||
|
}
|
||||||
|
runtime := input.Runtime
|
||||||
|
if runtime == "" {
|
||||||
|
runtime = "bun"
|
||||||
|
}
|
||||||
|
row, err := s.repo.Queries().CreateFunction(ctx, db.CreateFunctionParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Name: name,
|
||||||
|
Code: input.Code,
|
||||||
|
Runtime: runtime,
|
||||||
|
EventPattern: strings.TrimSpace(input.EventPattern),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return FunctionSummary{}, err
|
||||||
|
}
|
||||||
|
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "function.created", "function", row.ID.String(), map[string]any{
|
||||||
|
"name": row.Name, "runtime": row.Runtime,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return functionSummary(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListFunctions(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]FunctionSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListFunctions(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]FunctionSummary, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = functionSummary(r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFunctionCode returns the function's source — code is not a secret,
|
||||||
|
// project members already see it in the dashboard editor.
|
||||||
|
func (s *PlatformService) GetFunctionCode(ctx context.Context, actor *models.Actor, functionID uuid.UUID) (FunctionSummary, string, error) {
|
||||||
|
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||||
|
if err != nil {
|
||||||
|
return FunctionSummary{}, "", err
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return FunctionSummary{}, "", err
|
||||||
|
}
|
||||||
|
return functionSummary(row), row.Code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) UpdateFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, input UpdateFunctionInput, requestID string) (FunctionSummary, error) {
|
||||||
|
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||||
|
if err != nil {
|
||||||
|
return FunctionSummary{}, err
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||||
|
return FunctionSummary{}, err
|
||||||
|
}
|
||||||
|
var pattern *string
|
||||||
|
if input.EventPattern != nil {
|
||||||
|
p := strings.TrimSpace(*input.EventPattern)
|
||||||
|
pattern = &p
|
||||||
|
}
|
||||||
|
updated, err := s.repo.Queries().UpdateFunction(ctx, db.UpdateFunctionParams{
|
||||||
|
ID: functionID,
|
||||||
|
ProjectID: row.ProjectID,
|
||||||
|
Code: input.Code,
|
||||||
|
Enabled: input.Enabled,
|
||||||
|
EventPattern: pattern,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return FunctionSummary{}, err
|
||||||
|
}
|
||||||
|
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.updated", "function", functionID.String(), map[string]any{
|
||||||
|
"name": updated.Name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return functionSummary(updated), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) DeleteFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, requestID string) error {
|
||||||
|
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.repo.Queries().DeleteFunction(ctx, db.DeleteFunctionParams{
|
||||||
|
ID: functionID,
|
||||||
|
ProjectID: row.ProjectID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.deleted", "function", functionID.String(), map[string]any{
|
||||||
|
"name": row.Name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvokeFunction executes a function with a JSON payload, records the run,
|
||||||
|
// and returns its summary.
|
||||||
|
func (s *PlatformService) InvokeFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, payload json.RawMessage, requestID string) (FunctionRunSummary, error) {
|
||||||
|
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||||
|
if err != nil {
|
||||||
|
return FunctionRunSummary{}, err
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||||
|
return FunctionRunSummary{}, err
|
||||||
|
}
|
||||||
|
summary, err := s.executeFunction(ctx, row, payload, "manual")
|
||||||
|
if err != nil {
|
||||||
|
return FunctionRunSummary{}, err
|
||||||
|
}
|
||||||
|
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.invoked", "function", functionID.String(), map[string]any{
|
||||||
|
"name": row.Name, "status": summary.Status,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeFunction runs a function row and records the outcome — shared by
|
||||||
|
// manual invoke, schedule/hook delivery, and event triggers (no role check;
|
||||||
|
// callers hold their own auth context or are system paths).
|
||||||
|
func (s *PlatformService) executeFunction(ctx context.Context, row db.CoreFunction, payload json.RawMessage, trigger string) (FunctionRunSummary, error) {
|
||||||
|
if !row.Enabled {
|
||||||
|
return FunctionRunSummary{}, fmt.Errorf("function is disabled")
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
payload = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
if !json.Valid(payload) {
|
||||||
|
return FunctionRunSummary{}, fmt.Errorf("payload must be valid JSON")
|
||||||
|
}
|
||||||
|
if s.functions == nil {
|
||||||
|
return FunctionRunSummary{}, fmt.Errorf("functions runtime not configured")
|
||||||
|
}
|
||||||
|
result := s.functions.Run(ctx, row.Runtime, row.Code, payload)
|
||||||
|
var exit *int32
|
||||||
|
if result.ExitCode != nil {
|
||||||
|
exit = result.ExitCode
|
||||||
|
}
|
||||||
|
run, err := s.repo.Queries().InsertFunctionRun(ctx, db.InsertFunctionRunParams{
|
||||||
|
FunctionID: row.ID,
|
||||||
|
Trigger: trigger,
|
||||||
|
Status: result.Status,
|
||||||
|
ExitCode: exit,
|
||||||
|
Stdout: result.Stdout,
|
||||||
|
Stderr: result.Stderr,
|
||||||
|
DurationMs: int32(result.DurationMs),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return FunctionRunSummary{}, err
|
||||||
|
}
|
||||||
|
return runSummary(run), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runJobFunction is the scheduler's delivery path for function-targeted
|
||||||
|
// jobs: load the function, run it with the resolved payload, record the
|
||||||
|
// run. The job run's status_code mirrors the process exit code.
|
||||||
|
func (s *PlatformService) runJobFunction(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error) {
|
||||||
|
fn, err := s.repo.Queries().GetFunction(ctx, uuid.UUID(job.FunctionID.Bytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("target function missing: %w", err)
|
||||||
|
}
|
||||||
|
summary, err := s.executeFunction(ctx, fn, payload, triggerFromJobRun(run.TriggeredBy))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
code := int32(0)
|
||||||
|
if summary.ExitCode != nil {
|
||||||
|
code = *summary.ExitCode
|
||||||
|
}
|
||||||
|
if summary.Status != "success" {
|
||||||
|
return &code, fmt.Errorf("function %q %s: %s", fn.Name, summary.Status, truncate(summary.Stderr, 200))
|
||||||
|
}
|
||||||
|
return &code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerFromJobRun(triggeredBy string) string {
|
||||||
|
switch triggeredBy {
|
||||||
|
case "schedule", "hook", "manual":
|
||||||
|
return triggeredBy
|
||||||
|
default:
|
||||||
|
return "schedule"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggerFunctionsForEvent fans a domain event out to every enabled
|
||||||
|
// function whose event_pattern matches ("document.*", "*", …). Runs are
|
||||||
|
// detached — event publishers never block on user code.
|
||||||
|
// jarvis: ceiling pattern is a single * suffix/prefix wildcard via SQL
|
||||||
|
// LIKE; upgrade to a real matcher if multi-segment globs are needed.
|
||||||
|
func (s *PlatformService) triggerFunctionsForEvent(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
|
||||||
|
if s.functions == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListFunctionsForEvent(ctx, db.ListFunctionsForEventParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
EventPattern: eventType,
|
||||||
|
})
|
||||||
|
if err != nil || len(rows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"event": eventType,
|
||||||
|
"project_id": projectID.String(),
|
||||||
|
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"data": data,
|
||||||
|
})
|
||||||
|
for _, fn := range rows {
|
||||||
|
fn := fn
|
||||||
|
go func() {
|
||||||
|
// Detached context: the publishing request may finish first.
|
||||||
|
runCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := s.executeFunction(runCtx, fn, payload, "event"); err != nil {
|
||||||
|
s.logger.Warn("function event trigger failed", "function", fn.Name, "event", eventType, "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListFunctionRuns(ctx context.Context, actor *models.Actor, functionID uuid.UUID, limit int64) ([]FunctionRunSummary, error) {
|
||||||
|
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListFunctionRuns(ctx, db.ListFunctionRunsParams{
|
||||||
|
FunctionID: row.ID,
|
||||||
|
Limit: int32(limit),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]FunctionRunSummary, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = runSummary(r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests run the real bun binary — skipped when it is not installed,
|
||||||
|
// same convention as exec-dependent tests elsewhere.
|
||||||
|
func bunAvailable(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
r := NewExecRunner("auto", 5*time.Second, 4096)
|
||||||
|
res := r.Run(context.Background(), "bun", `console.log("probe")`, nil)
|
||||||
|
if strings.Contains(res.Stderr, "no functions runtime") || (res.Status == "error" && res.ExitCode == nil && strings.Contains(res.Stderr, "executable file not found")) {
|
||||||
|
t.Skip("bun not installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecRunnerSuccess(t *testing.T) {
|
||||||
|
bunAvailable(t)
|
||||||
|
r := NewExecRunner("auto", 10*time.Second, 4096)
|
||||||
|
res := r.Run(context.Background(), "bun",
|
||||||
|
`const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("got:" + p.x);`,
|
||||||
|
[]byte(`{"x":42}`))
|
||||||
|
if res.Status != "success" {
|
||||||
|
t.Fatalf("status=%s stderr=%s", res.Status, res.Stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Stdout, "got:42") {
|
||||||
|
t.Fatalf("stdout=%q", res.Stdout)
|
||||||
|
}
|
||||||
|
if res.DurationMs <= 0 {
|
||||||
|
t.Fatalf("duration=%d", res.DurationMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecRunnerErrorCapturesStderr(t *testing.T) {
|
||||||
|
bunAvailable(t)
|
||||||
|
r := NewExecRunner("auto", 10*time.Second, 4096)
|
||||||
|
res := r.Run(context.Background(), "bun", `throw new Error("test-boom")`, nil)
|
||||||
|
if res.Status != "error" {
|
||||||
|
t.Fatalf("status=%s", res.Status)
|
||||||
|
}
|
||||||
|
if res.ExitCode == nil || *res.ExitCode == 0 {
|
||||||
|
t.Fatalf("exit=%v", res.ExitCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Stderr, "test-boom") {
|
||||||
|
t.Fatalf("stderr=%q", res.Stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecRunnerTimeout(t *testing.T) {
|
||||||
|
bunAvailable(t)
|
||||||
|
r := NewExecRunner("auto", 500*time.Millisecond, 4096)
|
||||||
|
res := r.Run(context.Background(), "bun", `await new Promise(r => setTimeout(r, 10000))`, nil)
|
||||||
|
if res.Status != "timeout" {
|
||||||
|
t.Fatalf("status=%s", res.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitedWriterCaps(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
lw := &limitedWriter{w: &buf, max: 5}
|
||||||
|
if n, err := lw.Write([]byte("hello world")); err != nil || n != 11 {
|
||||||
|
t.Fatalf("write n=%d err=%v", n, err)
|
||||||
|
}
|
||||||
|
if buf.String() != "hello" {
|
||||||
|
t.Fatalf("got %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTriggerFromJobRun(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"schedule": "schedule",
|
||||||
|
"hook": "hook",
|
||||||
|
"manual": "manual",
|
||||||
|
"cli": "schedule",
|
||||||
|
"": "schedule",
|
||||||
|
} {
|
||||||
|
if got := triggerFromJobRun(in); got != want {
|
||||||
|
t.Fatalf("triggerFromJobRun(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Inbound hooks are project-scoped public ingest endpoints:
|
||||||
|
// POST /api/v1/hooks/<token>. A hook either republishes the body as an
|
||||||
|
// `inbound.received` domain event (webhooks + realtime fan out for free) or
|
||||||
|
// triggers a scheduled-job run with the body as the payload. When a secret is
|
||||||
|
// set, callers must sign the raw body with X-Primora-Signature — the same
|
||||||
|
// HMAC-SHA256 scheme outbound deliveries use.
|
||||||
|
|
||||||
|
const (
|
||||||
|
WebhookEventInboundReceived = "inbound.received"
|
||||||
|
|
||||||
|
InboundModeEvent = "event"
|
||||||
|
InboundModeJob = "job"
|
||||||
|
InboundModeFunction = "function"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InboundHookSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
JobID string `json:"job_id,omitempty"`
|
||||||
|
FunctionID string `json:"function_id,omitempty"`
|
||||||
|
HasSecret bool `json:"has_secret"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastReceivedAt *time.Time `json:"last_received_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateInboundHookInput struct {
|
||||||
|
Name string `json:"name" validate:"required"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
FunctionID string `json:"function_id"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InboundReceiveResult struct {
|
||||||
|
Received bool `json:"received"`
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
|
RunID string `json:"run_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func inboundHookSummary(row db.CoreInboundHook, publicURL string) InboundHookSummary {
|
||||||
|
out := InboundHookSummary{
|
||||||
|
ID: row.ID.String(),
|
||||||
|
ProjectID: row.ProjectID.String(),
|
||||||
|
Name: row.Name,
|
||||||
|
Token: row.Token,
|
||||||
|
URL: strings.TrimSuffix(publicURL, "/") + "/api/v1/hooks/" + row.Token,
|
||||||
|
Mode: row.Mode,
|
||||||
|
HasSecret: len(row.Secret) > 0,
|
||||||
|
Enabled: row.Enabled,
|
||||||
|
CreatedAt: row.CreatedAt.Time,
|
||||||
|
}
|
||||||
|
if row.JobID.Valid {
|
||||||
|
out.JobID = uuid.UUID(row.JobID.Bytes).String()
|
||||||
|
}
|
||||||
|
if row.FunctionID.Valid {
|
||||||
|
out.FunctionID = uuid.UUID(row.FunctionID.Bytes).String()
|
||||||
|
}
|
||||||
|
if row.LastReceivedAt.Valid {
|
||||||
|
t := row.LastReceivedAt.Time
|
||||||
|
out.LastReceivedAt = &t
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListInboundHooks(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]InboundHookSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListInboundHooks(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]InboundHookSummary, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = inboundHookSummary(r, s.publicURL)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) CreateInboundHook(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateInboundHookInput, requestID string) (InboundHookSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return InboundHookSummary{}, err
|
||||||
|
}
|
||||||
|
if !projectSecretNameRe.MatchString(input.Name) {
|
||||||
|
return InboundHookSummary{}, fmt.Errorf("invalid hook name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name)
|
||||||
|
}
|
||||||
|
mode := input.Mode
|
||||||
|
if mode == "" {
|
||||||
|
mode = InboundModeEvent
|
||||||
|
}
|
||||||
|
var jobID, functionID pgtype.UUID
|
||||||
|
switch mode {
|
||||||
|
case InboundModeJob:
|
||||||
|
parsed, err := uuid.Parse(input.JobID)
|
||||||
|
if err != nil {
|
||||||
|
return InboundHookSummary{}, errors.New("job mode requires a valid job_id")
|
||||||
|
}
|
||||||
|
job, err := s.repo.Queries().GetScheduledJobByID(ctx, parsed)
|
||||||
|
if err != nil || job.ProjectID != projectID {
|
||||||
|
return InboundHookSummary{}, errors.New("job not found in this project")
|
||||||
|
}
|
||||||
|
jobID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
|
case InboundModeFunction:
|
||||||
|
parsed, err := uuid.Parse(input.FunctionID)
|
||||||
|
if err != nil {
|
||||||
|
return InboundHookSummary{}, errors.New("function mode requires a valid function_id")
|
||||||
|
}
|
||||||
|
fn, err := s.repo.Queries().GetFunction(ctx, parsed)
|
||||||
|
if err != nil || fn.ProjectID != projectID {
|
||||||
|
return InboundHookSummary{}, errors.New("function not found in this project")
|
||||||
|
}
|
||||||
|
functionID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
|
case InboundModeEvent:
|
||||||
|
default:
|
||||||
|
return InboundHookSummary{}, fmt.Errorf("invalid mode %q — event, job, or function", input.Mode)
|
||||||
|
}
|
||||||
|
secretBytes := []byte{}
|
||||||
|
if input.Secret != "" {
|
||||||
|
if s.enc == nil {
|
||||||
|
return InboundHookSummary{}, errors.New("encryption is not configured — cannot store hook secrets")
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if secretBytes, err = s.enc.Encrypt([]byte(input.Secret)); err != nil {
|
||||||
|
return InboundHookSummary{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token := "prm_hook_" + randHex(16)
|
||||||
|
enabled := true
|
||||||
|
if input.Enabled != nil {
|
||||||
|
enabled = *input.Enabled
|
||||||
|
}
|
||||||
|
row, err := s.repo.Queries().CreateInboundHook(ctx, db.CreateInboundHookParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Name: input.Name,
|
||||||
|
Token: token,
|
||||||
|
Mode: mode,
|
||||||
|
JobID: jobID,
|
||||||
|
FunctionID: functionID,
|
||||||
|
Secret: secretBytes,
|
||||||
|
Enabled: enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "inbound_hooks_project_id_name_key") {
|
||||||
|
return InboundHookSummary{}, fmt.Errorf("an inbound hook named %s already exists", input.Name)
|
||||||
|
}
|
||||||
|
return InboundHookSummary{}, err
|
||||||
|
}
|
||||||
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.created", "inbound_hook", row.ID.String(), map[string]any{
|
||||||
|
"name": row.Name, "mode": row.Mode,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return inboundHookSummary(row, s.publicURL), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) DeleteInboundHook(ctx context.Context, actor *models.Actor, projectID, hookID uuid.UUID, requestID string) error {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.repo.Queries().GetInboundHook(ctx, db.GetInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.repo.Queries().DeleteInboundHook(ctx, db.DeleteInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.deleted", "inbound_hook", hookID.String(), nil))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiveInboundHook handles POST /api/v1/hooks/:token — unauthenticated; the
|
||||||
|
// token is the credential. Signature check applies when a secret is set.
|
||||||
|
func (s *PlatformService) ReceiveInboundHook(ctx context.Context, token string, signature string, body []byte) (InboundReceiveResult, error) {
|
||||||
|
hook, err := s.repo.Queries().GetInboundHookByToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return InboundReceiveResult{}, err // pgx.ErrNoRows → 404
|
||||||
|
}
|
||||||
|
if !hook.Enabled {
|
||||||
|
return InboundReceiveResult{}, errors.New("hook is disabled")
|
||||||
|
}
|
||||||
|
if len(hook.Secret) > 0 {
|
||||||
|
if s.enc == nil {
|
||||||
|
return InboundReceiveResult{}, errors.New("encryption is not configured")
|
||||||
|
}
|
||||||
|
plain, err := s.enc.Decrypt(hook.Secret)
|
||||||
|
if err != nil {
|
||||||
|
return InboundReceiveResult{}, err
|
||||||
|
}
|
||||||
|
want := "sha256=" + signBody(string(plain), body)
|
||||||
|
if subtle.ConstantTimeCompare([]byte(signature), []byte(want)) != 1 {
|
||||||
|
return InboundReceiveResult{}, errors.New("invalid signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if len(body) > 0 {
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return InboundReceiveResult{}, fmt.Errorf("invalid JSON body: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = s.repo.Queries().TouchInboundHook(ctx, hook.ID)
|
||||||
|
|
||||||
|
switch hook.Mode {
|
||||||
|
case InboundModeJob:
|
||||||
|
if s.scheduler == nil {
|
||||||
|
return InboundReceiveResult{}, errors.New("scheduler unavailable")
|
||||||
|
}
|
||||||
|
if !hook.JobID.Valid {
|
||||||
|
return InboundReceiveResult{}, errors.New("hook has no job configured")
|
||||||
|
}
|
||||||
|
job, err := s.repo.Queries().GetScheduledJobByID(ctx, hook.JobID.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return InboundReceiveResult{}, err
|
||||||
|
}
|
||||||
|
if payload != nil {
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
job.Payload = raw // received body replaces the configured payload
|
||||||
|
}
|
||||||
|
run, err := s.scheduler.enqueue(ctx, job, "hook")
|
||||||
|
if err != nil {
|
||||||
|
return InboundReceiveResult{}, err
|
||||||
|
}
|
||||||
|
return InboundReceiveResult{Received: true, Event: WebhookEventJobRun, RunID: run.ID.String()}, nil
|
||||||
|
case InboundModeFunction:
|
||||||
|
if s.functions == nil {
|
||||||
|
return InboundReceiveResult{}, errors.New("functions runtime not configured")
|
||||||
|
}
|
||||||
|
if !hook.FunctionID.Valid {
|
||||||
|
return InboundReceiveResult{}, errors.New("hook has no function configured")
|
||||||
|
}
|
||||||
|
fn, err := s.repo.Queries().GetFunction(ctx, uuid.UUID(hook.FunctionID.Bytes))
|
||||||
|
if err != nil {
|
||||||
|
return InboundReceiveResult{}, err
|
||||||
|
}
|
||||||
|
raw := body
|
||||||
|
if len(raw) == 0 {
|
||||||
|
raw = []byte(`{}`)
|
||||||
|
}
|
||||||
|
// Detached: the public endpoint acks immediately; the run row records
|
||||||
|
// the outcome. Hook callers must not wait on user code.
|
||||||
|
go func() {
|
||||||
|
runCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := s.executeFunction(runCtx, fn, raw, "hook"); err != nil && s.logger != nil {
|
||||||
|
s.logger.Warn("hook function invoke failed", "hook", hook.Name, "function", fn.Name, "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return InboundReceiveResult{Received: true, Event: "function.invoked"}, nil
|
||||||
|
default:
|
||||||
|
s.publishEvent(ctx, hook.ProjectID, WebhookEventInboundReceived, map[string]any{
|
||||||
|
"hook_id": hook.ID.String(),
|
||||||
|
"hook_name": hook.Name,
|
||||||
|
"payload": payload,
|
||||||
|
})
|
||||||
|
return InboundReceiveResult{Received: true, Event: WebhookEventInboundReceived}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randHex(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
@@ -104,4 +104,39 @@ func TestValidateWebhookEvents(t *testing.T) {
|
|||||||
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
|
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
|
||||||
t.Error("expected unknown event rejected")
|
t.Error("expected unknown event rejected")
|
||||||
}
|
}
|
||||||
|
for _, ok := range [][]string{
|
||||||
|
{"*"},
|
||||||
|
{"document.*"},
|
||||||
|
{"custom.deploy.done"},
|
||||||
|
{"custom.*"},
|
||||||
|
{"custom.deploy.*"},
|
||||||
|
} {
|
||||||
|
if _, err := validateWebhookEvents(ok); err != nil {
|
||||||
|
t.Errorf("expected %v accepted, got %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range [][]string{
|
||||||
|
{"banana.*"}, {"custom..*"}, {"custom.UPPER"}, {"document.exploded"},
|
||||||
|
} {
|
||||||
|
if _, err := validateWebhookEvents(bad); err == nil {
|
||||||
|
t.Errorf("expected %v rejected", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivateWebhookHostHardening(t *testing.T) {
|
||||||
|
private := []string{"localhost", "minio", "echo-svc", "host.docker.internal",
|
||||||
|
"10.0.0.4", "172.16.5.5", "192.168.1.10", "fe80::1", "svc.local", "db.internal"}
|
||||||
|
public := []string{"169.254.169.254", "fd00:ec2::254", "2130706433", "0x7f000001",
|
||||||
|
"8.8.8.8", "example.com", "999999999999"}
|
||||||
|
for _, h := range private {
|
||||||
|
if !isPrivateWebhookHost(h) {
|
||||||
|
t.Errorf("expected %q private", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, h := range public {
|
||||||
|
if isPrivateWebhookHost(h) {
|
||||||
|
t.Errorf("expected %q public/blocked", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
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/jackc/pgx/v5/pgxpool"
|
||||||
|
"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"`
|
||||||
|
FunctionID *string `json:"function_id,omitempty"`
|
||||||
|
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_without=FunctionID,omitempty,url"`
|
||||||
|
FunctionID string `json:"function_id" validate:"omitempty,uuid"`
|
||||||
|
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"`
|
||||||
|
FunctionID *string `json:"function_id" validate:"omitempty,uuid"`
|
||||||
|
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.FunctionID.Valid {
|
||||||
|
id := uuid.UUID(row.FunctionID.Bytes).String()
|
||||||
|
out.FunctionID = &id
|
||||||
|
}
|
||||||
|
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 := ""
|
||||||
|
var functionID pgtype.UUID
|
||||||
|
if input.FunctionID != "" {
|
||||||
|
fid, err := uuid.Parse(input.FunctionID)
|
||||||
|
if err != nil {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
|
||||||
|
}
|
||||||
|
fn, err := s.repo.Queries().GetFunction(ctx, fid)
|
||||||
|
if err != nil || fn.ProjectID != projectID {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
|
||||||
|
}
|
||||||
|
functionID = pgtype.UUID{Bytes: fid, Valid: true}
|
||||||
|
jobURL = "function://" + fid.String()
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
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,
|
||||||
|
FunctionID: functionID,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rotateFunction := input.FunctionID != nil
|
||||||
|
functionID := row.FunctionID
|
||||||
|
if rotateFunction {
|
||||||
|
raw := strings.TrimSpace(*input.FunctionID)
|
||||||
|
if raw == "" {
|
||||||
|
functionID = pgtype.UUID{} // explicit clear → back to URL delivery
|
||||||
|
if input.URL == nil && strings.HasPrefix(jobURL, "function://") {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("clearing function_id requires a url")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fid, ferr := uuid.Parse(raw)
|
||||||
|
if ferr != nil {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
|
||||||
|
}
|
||||||
|
fn, ferr := s.repo.Queries().GetFunction(ctx, fid)
|
||||||
|
if ferr != nil || fn.ProjectID != projectID {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
|
||||||
|
}
|
||||||
|
functionID = pgtype.UUID{Bytes: fid, Valid: true}
|
||||||
|
jobURL = "function://" + fid.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
Column12: rotateFunction,
|
||||||
|
FunctionID: functionID,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]db.ListProjectSecretValuesRow, 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
|
||||||
|
leader *leaderState
|
||||||
|
// 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)
|
||||||
|
// runFn delivers to a function instead of HTTP when job.FunctionID is set.
|
||||||
|
// The function receives the resolved job payload, not the run envelope.
|
||||||
|
// Owned by PlatformService — the scheduler stays transport-agnostic.
|
||||||
|
runFn func(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
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), pool *pgxpool.Pool) *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,
|
||||||
|
leader: newLeaderState(pool, leaderKeyScheduler),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
// Only the leader fires scheduled runs — manual/hook enqueues are
|
||||||
|
// already scoped to whichever replica received the request.
|
||||||
|
if j.leader.hold(ctx) {
|
||||||
|
j.scan()
|
||||||
|
}
|
||||||
|
case <-j.quit:
|
||||||
|
j.leader.stop(context.Background())
|
||||||
|
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 — or invokes the target function
|
||||||
|
// when the job carries a function_id.
|
||||||
|
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
|
||||||
|
data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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": data,
|
||||||
|
})
|
||||||
|
if job.FunctionID.Valid {
|
||||||
|
if j.runFn == nil {
|
||||||
|
return nil, fmt.Errorf("functions runtime not configured")
|
||||||
|
}
|
||||||
|
return j.runFn(ctx, job, run, data)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Leader election via Postgres advisory locks. The lock is session-scoped, so
|
||||||
|
// the lease pins its pool connection for its entire lifetime — a handoff back
|
||||||
|
// to the pool would silently drop it. A single-replica deployment simply
|
||||||
|
// always holds the lock; there is no feature flag.
|
||||||
|
//
|
||||||
|
// Distinct keys per component so the job scheduler and the alert evaluator
|
||||||
|
// elect independently.
|
||||||
|
const (
|
||||||
|
leaderKeyScheduler int64 = 728201
|
||||||
|
leaderKeyAlerts int64 = 728202
|
||||||
|
)
|
||||||
|
|
||||||
|
type leaderLease struct {
|
||||||
|
conn *pgxpool.Conn
|
||||||
|
key int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquireLeadership returns a lease when this process wins the lock, nil when
|
||||||
|
// another replica holds it, or an error when the check itself failed.
|
||||||
|
func acquireLeadership(ctx context.Context, pool *pgxpool.Pool, key int64) (*leaderLease, error) {
|
||||||
|
conn, err := pool.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var got bool
|
||||||
|
if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&got); err != nil {
|
||||||
|
conn.Release()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !got {
|
||||||
|
conn.Release()
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &leaderLease{conn: conn, key: key}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// alive pings the pinned connection — a dead conn means Postgres dropped the
|
||||||
|
// session and released the lock, so leadership is lost even though the lease
|
||||||
|
// object still exists.
|
||||||
|
func (l *leaderLease) alive(ctx context.Context) bool {
|
||||||
|
var one int
|
||||||
|
return l.conn.QueryRow(ctx, "SELECT 1").Scan(&one) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *leaderLease) release(ctx context.Context) {
|
||||||
|
_, _ = l.conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", l.key)
|
||||||
|
l.conn.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
// leaderState tracks a lease inside a ticker loop — acquire when absent,
|
||||||
|
// verify when held, drop and re-elect when the connection dies.
|
||||||
|
type leaderState struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
key int64
|
||||||
|
lease *leaderLease
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLeaderState(pool *pgxpool.Pool, key int64) *leaderState {
|
||||||
|
return &leaderState{pool: pool, key: key}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hold reports whether this process currently leads. A nil pool means no
|
||||||
|
// election configured (tests, embedded use) — always lead.
|
||||||
|
func (l *leaderState) hold(ctx context.Context) bool {
|
||||||
|
if l == nil || l.pool == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if l.lease != nil {
|
||||||
|
if l.lease.alive(ctx) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
l.lease.release(ctx)
|
||||||
|
l.lease = nil
|
||||||
|
}
|
||||||
|
lease, err := acquireLeadership(ctx, l.pool, l.key)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
l.lease = lease
|
||||||
|
return lease != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *leaderState) stop(ctx context.Context) {
|
||||||
|
if l != nil && l.lease != nil {
|
||||||
|
l.lease.release(ctx)
|
||||||
|
l.lease = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +11,9 @@ import (
|
|||||||
"github.com/tdvorak/primora/apps/backend/internal/config"
|
"github.com/tdvorak/primora/apps/backend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Mailer is transport-only: Resend when configured, plain SMTP otherwise.
|
||||||
|
// Templates render (subject, body) from data; sends are logged to
|
||||||
|
// core.email_log by the service layer so operators can see what went out.
|
||||||
type Mailer struct {
|
type Mailer struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
resend *resend.Client
|
resend *resend.Client
|
||||||
@@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer {
|
|||||||
return &Mailer{cfg: cfg, resend: resendClient}
|
return &Mailer{cfg: cfg, resend: resendClient}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error {
|
// mailTemplate renders a subject + plaintext body from template data.
|
||||||
subject := fmt.Sprintf("You were invited to %s on Primora", organizationName)
|
type mailTemplate func(data map[string]string) (subject string, body string)
|
||||||
text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n"
|
|
||||||
|
var mailTemplates = map[string]mailTemplate{
|
||||||
|
"invitation": func(d map[string]string) (string, string) {
|
||||||
|
subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"])
|
||||||
|
body := fmt.Sprintf(
|
||||||
|
"You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n",
|
||||||
|
d["invite_url"],
|
||||||
|
)
|
||||||
|
return subject, body
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMailTemplate(template string, data map[string]string) (string, string, error) {
|
||||||
|
tpl, ok := mailTemplates[template]
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("unknown email template %q", template)
|
||||||
|
}
|
||||||
|
subject, body := tpl(data)
|
||||||
|
return subject, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error {
|
||||||
if m.resend != nil {
|
if m.resend != nil {
|
||||||
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
||||||
From: m.cfg.MailFrom,
|
From: m.cfg.MailFrom,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderMailTemplateInvitation(t *testing.T) {
|
||||||
|
subject, body, err := renderMailTemplate("invitation", map[string]string{
|
||||||
|
"organization": "Acme Corp",
|
||||||
|
"invite_url": "https://app.primora.dev/accept?token=abc",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "Acme Corp") {
|
||||||
|
t.Fatalf("subject missing organization: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "https://app.primora.dev/accept?token=abc") {
|
||||||
|
t.Fatalf("body missing invite url: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMailTemplateUnknown(t *testing.T) {
|
||||||
|
if _, _, err := renderMailTemplate("does-not-exist", nil); err == nil {
|
||||||
|
t.Fatal("expected error for unknown template")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractEmail(t *testing.T) {
|
||||||
|
if got := extractEmail("Primora <[email protected]>"); got != "[email protected]" {
|
||||||
|
t.Fatalf("extractEmail = %q", got)
|
||||||
|
}
|
||||||
|
if got := extractEmail("[email protected]"); got != "[email protected]" {
|
||||||
|
t.Fatalf("extractEmail bare = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -29,14 +32,22 @@ import (
|
|||||||
|
|
||||||
type PlatformService struct {
|
type PlatformService struct {
|
||||||
repo *repositories.CoreRepository
|
repo *repositories.CoreRepository
|
||||||
store *storage.LocalStore
|
store storage.Store
|
||||||
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
|
||||||
|
alerts *alertEvaluator
|
||||||
|
functions FunctionRunner
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
// presenceMu guards presence: realtime subscriber counts per project.
|
||||||
|
presenceMu sync.Mutex
|
||||||
|
presence map[uuid.UUID]int
|
||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapInput struct {
|
type BootstrapInput struct {
|
||||||
@@ -67,6 +78,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 +94,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 +233,221 @@ 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.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *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, functions: functions, logger: logger, presence: map[uuid.UUID]int{}}
|
||||||
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, repo.Pool())
|
||||||
|
s.scheduler.runFn = s.runJobFunction
|
||||||
|
s.scheduler.Start(context.Background())
|
||||||
|
s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool())
|
||||||
|
s.alerts.Start(context.Background())
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close stops the webhook dispatcher. Idempotent.
|
// sendTemplatedEmail renders a registered template, sends it, and writes an
|
||||||
|
// email_log row with the outcome — failed sends are still logged so the
|
||||||
|
// dashboard shows them.
|
||||||
|
func (s *PlatformService) sendTemplatedEmail(ctx context.Context, projectID *uuid.UUID, template, to string, data map[string]string) error {
|
||||||
|
subject, body, err := renderMailTemplate(template, data)
|
||||||
|
status, errText := "sent", ""
|
||||||
|
if err == nil {
|
||||||
|
if sendErr := s.mailer.Send(ctx, to, subject, body); sendErr != nil {
|
||||||
|
status, errText, err = "failed", sendErr.Error(), sendErr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status, errText = "failed", err.Error()
|
||||||
|
}
|
||||||
|
var pid pgtype.UUID
|
||||||
|
if projectID != nil {
|
||||||
|
pid = pgtype.UUID{Bytes: *projectID, Valid: true}
|
||||||
|
}
|
||||||
|
_, _ = s.repo.Queries().InsertEmailLog(ctx, db.InsertEmailLogParams{
|
||||||
|
ProjectID: pid,
|
||||||
|
Template: template,
|
||||||
|
ToEmail: to,
|
||||||
|
Subject: subject,
|
||||||
|
Status: status,
|
||||||
|
Error: errText,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailLogSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
ToEmail string `json:"to_email"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListEmailLog(ctx context.Context, actor *models.Actor, projectID uuid.UUID, limit int64) ([]EmailLogSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListEmailLog(ctx, db.ListEmailLogParams{ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, Limit: int32(limit)})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]EmailLogSummary, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = EmailLogSummary{
|
||||||
|
ID: r.ID.String(),
|
||||||
|
Template: r.Template,
|
||||||
|
ToEmail: r.ToEmail,
|
||||||
|
Subject: r.Subject,
|
||||||
|
Status: r.Status,
|
||||||
|
Error: r.Error,
|
||||||
|
CreatedAt: r.CreatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
if s.alerts != nil {
|
||||||
|
s.alerts.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)
|
||||||
|
s.triggerFunctionsForEvent(ctx, projectID, eventType, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeRealtime registers an SSE consumer for project domain events and
|
||||||
|
// bumps the project's presence count.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
ch := s.realtime.Subscribe(projectID.String())
|
||||||
|
s.bumpPresence(projectID, 1)
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
|
||||||
|
s.realtime.Unsubscribe(projectID.String(), ch)
|
||||||
|
s.bumpPresence(projectID, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bumpPresence adjusts the online count and broadcasts presence.update to
|
||||||
|
// realtime subscribers only — deliberately not through publishEvent, so
|
||||||
|
// webhooks and `event_pattern: "*"` functions don't fire on connect noise.
|
||||||
|
func (s *PlatformService) bumpPresence(projectID uuid.UUID, delta int) {
|
||||||
|
s.presenceMu.Lock()
|
||||||
|
n := s.presence[projectID] + delta
|
||||||
|
if n <= 0 {
|
||||||
|
delete(s.presence, projectID)
|
||||||
|
n = 0
|
||||||
|
} else {
|
||||||
|
s.presence[projectID] = n
|
||||||
|
}
|
||||||
|
s.presenceMu.Unlock()
|
||||||
|
s.realtime.Broadcast(projectID.String(), map[string]any{
|
||||||
|
"type": "presence.update",
|
||||||
|
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"data": map[string]any{"online": n},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presence returns the current realtime subscriber count for a project.
|
||||||
|
func (s *PlatformService) Presence(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (int, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
s.presenceMu.Lock()
|
||||||
|
defer s.presenceMu.Unlock()
|
||||||
|
return s.presence[projectID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var customEventTypeRe = regexp.MustCompile(`^custom\.[a-z0-9][a-z0-9_.-]{0,62}$`)
|
||||||
|
|
||||||
|
// PublishProjectEvent lets a member emit a custom domain event — realtime
|
||||||
|
// subscribers, matching webhooks, and event_pattern functions all fire via
|
||||||
|
// the standard publishEvent fan-out. The custom.* prefix is enforced so
|
||||||
|
// clients can't spoof system event types (document.created etc.).
|
||||||
|
func (s *PlatformService) PublishProjectEvent(ctx context.Context, actor *models.Actor, projectID uuid.UUID, eventType string, data map[string]any) error {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !customEventTypeRe.MatchString(eventType) {
|
||||||
|
return errors.New("invalid event type: must match custom.<name> (lowercase letters, digits, dots, dashes)")
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
data = map[string]any{}
|
||||||
|
}
|
||||||
|
s.publishEvent(ctx, projectID, eventType, data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActorContext describes the caller's own scope — the self-discovery
|
||||||
|
// endpoint for API keys, which cannot call /me. User actors get just the
|
||||||
|
// type marker; their full view is PlatformSummary.
|
||||||
|
type ActorContext struct {
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
KeyPrefix string `json:"key_prefix,omitempty"`
|
||||||
|
Organization *ContextOrgInfo `json:"organization,omitempty"`
|
||||||
|
Project *ContextRef `json:"project,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextOrgInfo struct {
|
||||||
|
ContextRef
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextRef struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ActorContext(ctx context.Context, actor *models.Actor) (ActorContext, error) {
|
||||||
|
out := ActorContext{Actor: string(actor.Type)}
|
||||||
|
if actor.Type != models.ActorTypeAPIKey {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
out.Scopes = actor.Scopes
|
||||||
|
out.KeyPrefix = actor.APIKeyPrefix
|
||||||
|
if actor.ProjectID == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
project, err := s.repo.Queries().GetProjectByID(ctx, *actor.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Project = &ContextRef{ID: project.ID, Slug: project.Slug, Name: project.Name}
|
||||||
|
org, err := s.repo.Queries().GetOrganizationByID(ctx, project.OrganizationID)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Organization = &ContextOrgInfo{ContextRef: ContextRef{ID: org.ID, Slug: org.Slug, Name: org.Name}}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +954,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
|
||||||
@@ -866,7 +1087,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
|
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
|
||||||
}
|
}
|
||||||
if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil {
|
var inviteProject *uuid.UUID
|
||||||
|
if projectUUID.Valid {
|
||||||
|
id := uuid.UUID(projectUUID.Bytes)
|
||||||
|
inviteProject = &id
|
||||||
|
}
|
||||||
|
if err := s.sendTemplatedEmail(ctx, inviteProject, "invitation", invitation.Email, map[string]string{
|
||||||
|
"organization": "Primora",
|
||||||
|
"invite_url": inviteURL,
|
||||||
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
@@ -1038,6 +1267,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 +1276,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 +1293,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 +1463,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1269,6 +1524,64 @@ func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bu
|
|||||||
return object, file, nil
|
return object, file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrPresignUnsupported — the configured storage driver can't mint signed
|
||||||
|
// URLs (only the S3 driver can). Handlers map it to 400, not 500.
|
||||||
|
var ErrPresignUnsupported = errors.New("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
|
||||||
|
|
||||||
|
// InputError marks a service-layer failure caused by caller input — the
|
||||||
|
// HTTP layer maps it to 400 instead of 500.
|
||||||
|
type InputError struct{ msg string }
|
||||||
|
|
||||||
|
func (e *InputError) Error() string { return e.msg }
|
||||||
|
|
||||||
|
func NewInputError(msg string) error { return &InputError{msg: msg} }
|
||||||
|
|
||||||
|
func inputErrorf(format string, args ...any) error {
|
||||||
|
return &InputError{msg: fmt.Sprintf(format, args...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignObject mints a short-lived direct-to-storage URL for a single
|
||||||
|
// object. Requires the s3 storage driver — the local store has no external
|
||||||
|
// URL surface to sign against.
|
||||||
|
func (s *PlatformService) PresignObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, op string, ttlSeconds int) (*storage.PresignedURL, error) {
|
||||||
|
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
method := ""
|
||||||
|
switch op {
|
||||||
|
case "download", "":
|
||||||
|
method = http.MethodGet
|
||||||
|
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !objectExists(ctx, s, bucketID, objectKey) {
|
||||||
|
return nil, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
case "upload":
|
||||||
|
method = http.MethodPut
|
||||||
|
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid op %q — download or upload", op)
|
||||||
|
}
|
||||||
|
p, ok := s.store.(storage.Presigner)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrPresignUnsupported
|
||||||
|
}
|
||||||
|
ttl := time.Duration(ttlSeconds) * time.Second
|
||||||
|
return p.Presign(ctx, bucketID.String(), objectKey, method, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func objectExists(ctx context.Context, s *PlatformService, bucketID uuid.UUID, key string) bool {
|
||||||
|
_, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
|
||||||
|
BucketID: bucketID,
|
||||||
|
ObjectKey: key,
|
||||||
|
})
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
|
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
|
||||||
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1335,6 +1648,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 +1735,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 +1768,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 +1995,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 +2024,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 +2050,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,234 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Project secrets are the server-side counterpart of the CLI's local vault:
|
||||||
|
// AES-256-GCM at rest, metadata-only listings, audited reveal. Job payloads
|
||||||
|
// reference them as secret://NAME — resolved at delivery, never stored in
|
||||||
|
// plaintext config.
|
||||||
|
|
||||||
|
var projectSecretNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
|
||||||
|
type ProjectSecretSummary struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetProjectSecretInput struct {
|
||||||
|
Name string `json:"name"` // from the path param, not the body
|
||||||
|
Value string `json:"value" validate:"required"`
|
||||||
|
URL *string `json:"url"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RevealProjectSecretResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProjectSecretSummary(row db.ListProjectSecretsRow) ProjectSecretSummary {
|
||||||
|
return ProjectSecretSummary{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
URL: row.Url,
|
||||||
|
Notes: row.Notes,
|
||||||
|
CreatedAt: row.CreatedAt.Time,
|
||||||
|
UpdatedAt: row.UpdatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListProjectSecrets(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ProjectSecretSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListProjectSecrets(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]ProjectSecretSummary, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, toProjectSecretSummary(row))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) SetProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input SetProjectSecretInput, requestID string) (ProjectSecretSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return ProjectSecretSummary{}, err
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if !projectSecretNameRe.MatchString(name) {
|
||||||
|
return ProjectSecretSummary{}, fmt.Errorf("invalid secret name — use env-var-safe names [A-Za-z_][A-Za-z0-9_]*")
|
||||||
|
}
|
||||||
|
if s.enc == nil {
|
||||||
|
return ProjectSecretSummary{}, fmt.Errorf("encryption is not configured on this deployment")
|
||||||
|
}
|
||||||
|
ciphertext, err := s.enc.Encrypt([]byte(input.Value))
|
||||||
|
if err != nil {
|
||||||
|
return ProjectSecretSummary{}, err
|
||||||
|
}
|
||||||
|
// Merge url/notes over the existing row when the caller omits them —
|
||||||
|
// `secrets set NAME` should not wipe metadata it never saw.
|
||||||
|
var url, notes string
|
||||||
|
if existing, err := s.repo.Queries().GetProjectSecret(ctx, db.GetProjectSecretParams{ProjectID: projectID, Name: name}); err == nil {
|
||||||
|
url, notes = existing.Url, existing.Notes
|
||||||
|
} else if err != pgx.ErrNoRows {
|
||||||
|
return ProjectSecretSummary{}, err
|
||||||
|
}
|
||||||
|
if input.URL != nil {
|
||||||
|
url = strings.TrimSpace(*input.URL)
|
||||||
|
}
|
||||||
|
if input.Notes != nil {
|
||||||
|
notes = strings.TrimSpace(*input.Notes)
|
||||||
|
}
|
||||||
|
row, err := s.repo.Queries().UpsertProjectSecret(ctx, db.UpsertProjectSecretParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Name: name,
|
||||||
|
Ciphertext: ciphertext,
|
||||||
|
Url: url,
|
||||||
|
Notes: notes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ProjectSecretSummary{}, err
|
||||||
|
}
|
||||||
|
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.set", "project_secret", row.ID.String(), map[string]any{
|
||||||
|
"name": row.Name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return ProjectSecretSummary{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
URL: row.Url,
|
||||||
|
Notes: row.Notes,
|
||||||
|
CreatedAt: row.CreatedAt.Time,
|
||||||
|
UpdatedAt: row.UpdatedAt.Time,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) DeleteProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name string, requestID string) error {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row, err := s.repo.Queries().DeleteProjectSecret(ctx, db.DeleteProjectSecretParams{ProjectID: projectID, Name: name})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.deleted", "project_secret", row.String(), map[string]any{
|
||||||
|
"name": name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal decrypts a single secret. Deliberately loud: audit-logged every time.
|
||||||
|
func (s *PlatformService) RevealProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name string, requestID string) (RevealProjectSecretResponse, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return RevealProjectSecretResponse{}, err
|
||||||
|
}
|
||||||
|
row, err := s.repo.Queries().GetProjectSecret(ctx, db.GetProjectSecretParams{ProjectID: projectID, Name: name})
|
||||||
|
if err != nil {
|
||||||
|
return RevealProjectSecretResponse{}, err
|
||||||
|
}
|
||||||
|
plain, err := s.enc.Decrypt(row.Ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return RevealProjectSecretResponse{}, err
|
||||||
|
}
|
||||||
|
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.revealed", "project_secret", row.ID.String(), map[string]any{
|
||||||
|
"name": row.Name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return RevealProjectSecretResponse{Name: row.Name, Value: string(plain)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSecretRefs replaces "secret://NAME" string values in a payload with
|
||||||
|
// decrypted values. Names that do not resolve fail loudly — a literal
|
||||||
|
// secret:// string reaching the endpoint is worse than a failed run.
|
||||||
|
// Package-level so the JobScheduler can reuse it through its narrow repo iface.
|
||||||
|
func resolveSecretRefs(
|
||||||
|
ctx context.Context,
|
||||||
|
list func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error),
|
||||||
|
enc *secrets.Encryptor,
|
||||||
|
projectID uuid.UUID,
|
||||||
|
payload json.RawMessage,
|
||||||
|
) (json.RawMessage, error) {
|
||||||
|
if len(payload) == 0 || !strings.Contains(string(payload), "secret://") {
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
if enc == nil {
|
||||||
|
return nil, fmt.Errorf("payload references project secrets but encryption is not configured on this deployment")
|
||||||
|
}
|
||||||
|
rows, err := list(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values := make(map[string]string, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
plain, err := enc.Decrypt(row.Ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("project secret %q failed to decrypt", row.Name)
|
||||||
|
}
|
||||||
|
values[row.Name] = string(plain)
|
||||||
|
}
|
||||||
|
var doc any
|
||||||
|
if err := json.Unmarshal(payload, &doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("payload is not valid JSON: %w", err)
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
resolved := walkSecretRefs(doc, values, &missing)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return nil, fmt.Errorf("unresolved secret references: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkSecretRefs(node any, values map[string]string, missing *[]string) any {
|
||||||
|
switch v := node.(type) {
|
||||||
|
case string:
|
||||||
|
if strings.HasPrefix(v, "secret://") {
|
||||||
|
name := strings.TrimPrefix(v, "secret://")
|
||||||
|
if resolved, ok := values[name]; ok {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
*missing = append(*missing, name)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case map[string]any:
|
||||||
|
for k, item := range v {
|
||||||
|
v[k] = walkSecretRefs(item, values, missing)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
case []any:
|
||||||
|
for i, item := range v {
|
||||||
|
v[i] = walkSecretRefs(item, values, missing)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
default:
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 325 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user