mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-29 16:14:53 +00:00
Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a49de2db5 | ||
|
|
61a45cc48f | ||
|
|
d44bbcceda | ||
|
|
56c63d43cd | ||
|
|
6d758c0089 | ||
|
|
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=
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
github: Dvorinka
|
||||||
+160
-21
@@ -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
|
||||||
- name: Build auth image
|
cache-from: type=gha,scope=${{ matrix.app }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.app }}
|
||||||
|
|
||||||
|
- name: Smoke check ${{ matrix.app }} image contents
|
||||||
|
run: |
|
||||||
|
case "${{ matrix.app }}" in
|
||||||
|
backend) docker run --rm --entrypoint sh primora-backend:test -c 'test -x /usr/local/bin/primora-backend && test -x /usr/local/bin/dbx-mcp' ;;
|
||||||
|
auth) docker run --rm --entrypoint sh primora-auth:test -c 'test -f /workspace/apps/auth/dist/index.js' ;;
|
||||||
|
frontend) docker run --rm --entrypoint sh primora-frontend:test -c 'test -f /usr/share/nginx/html/index.html' ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# 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/auth/Dockerfile
|
file: apps/${{ matrix.app }}/Dockerfile
|
||||||
push: false
|
push: true
|
||||||
tags: primora-auth:test
|
tags: |
|
||||||
|
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:edge
|
||||||
- name: Build frontend image
|
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:sha-${{ github.sha }}
|
||||||
uses: docker/build-push-action@v6
|
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:
|
with:
|
||||||
context: .
|
go-version: '1.26'
|
||||||
file: apps/frontend/Dockerfile
|
cache-dependency-path: apps/backend/go.sum
|
||||||
push: false
|
|
||||||
tags: primora-frontend:test
|
- 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/
|
||||||
+158
@@ -4,6 +4,164 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **App logins** - a username and password bound to a stable app id, in the local vault and in the project vault. The app id is not a host or a port, so `localhost:3000` can change without losing the account. `primora logins set|get|list|rm` (`--remote` for the project). The dashboard Vault page and the desktop vault window manage both.
|
||||||
|
- **Agent reads** - `primora_vault_get`, `primora_secrets_get`, `primora_logins_get`, `primora_logins_set`, and `primora_logins_list`. An agent fetches one named value when it needs to sign in. Lists still omit passwords. Project reveals stay audit-logged. Do not paste the value into chat or a `.env` file.
|
||||||
|
|
||||||
|
## [0.8.5] - 2026-09-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Content column centers on wide screens** — `.page` was capped at 72rem but left-aligned, so above ~1200px the dashboard hugged the left with a growing dead zone on the right. It now centers (`margin-inline: auto`) while staying full-width below the cap.
|
||||||
|
|
||||||
|
## [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,179 @@
|
|||||||
|
# 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 — informational only; the shipped image resolves API/auth URLs same-origin at runtime, so no rebuild is needed when the host changes | 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`, `BACKEND_PUBLIC_URL`, and
|
||||||
|
`COOKIE_DOMAIN` to the https origin and restart the stack
|
||||||
|
(`docker compose up -d`). The frontend resolves API/auth URLs
|
||||||
|
same-origin, so no image rebuild is needed — HTTPS is still required for
|
||||||
|
PWA install, service workers, and other secure-context features on
|
||||||
|
non-localhost origins.
|
||||||
|
|
||||||
|
## 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. App logins (username, password, stable app id) live in both, and an agent can read one named value when it needs to sign in.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
|||||||
+327
-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,334 @@ 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`.
|
||||||
|
|
||||||
|
## App logins (shipped, unreleased)
|
||||||
|
|
||||||
|
Username and password for a website or for an app the user is building.
|
||||||
|
Stored in both the local vault and the project vault. The app id is a
|
||||||
|
stable name (`my-app`, the project slug), not a host or a port, because
|
||||||
|
`localhost:3000` can serve something else later. `origin` is only the
|
||||||
|
current URL.
|
||||||
|
|
||||||
|
Agents read one named secret or login when they need it
|
||||||
|
(`primora_vault_get`, `primora_secrets_get`, `primora_logins_get`).
|
||||||
|
That is the point of the vault: encrypted at rest, fetched at use, not
|
||||||
|
pasted into chat and not written to `.env`. Lists stay metadata.
|
||||||
|
Project reveals are audit-logged. A `write` API key can reveal; a `read`
|
||||||
|
key cannot.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- App logins: username + password bound to a stable app id, not a host:port.
|
||||||
|
-- The password is AES-256-GCM ciphertext. List returns metadata (including
|
||||||
|
-- the username and origin); reveal decrypts and is audit-logged.
|
||||||
|
CREATE TABLE core.project_logins (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
app TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
ciphertext BYTEA NOT NULL,
|
||||||
|
origin 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, app)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS core.project_logins;
|
||||||
@@ -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,33 @@
|
|||||||
|
-- name: ListProjectLogins :many
|
||||||
|
SELECT id, project_id, app, username, origin, notes, created_at, updated_at
|
||||||
|
FROM core.project_logins
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY app ASC;
|
||||||
|
|
||||||
|
-- name: GetProjectLogin :one
|
||||||
|
SELECT * FROM core.project_logins
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND app = $2;
|
||||||
|
|
||||||
|
-- name: UpsertProjectLogin :one
|
||||||
|
INSERT INTO core.project_logins (
|
||||||
|
project_id,
|
||||||
|
app,
|
||||||
|
username,
|
||||||
|
ciphertext,
|
||||||
|
origin,
|
||||||
|
notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (project_id, app) DO UPDATE SET
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
ciphertext = EXCLUDED.ciphertext,
|
||||||
|
origin = EXCLUDED.origin,
|
||||||
|
notes = EXCLUDED.notes,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteProjectLogin :one
|
||||||
|
DELETE FROM core.project_logins
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND app = $2
|
||||||
|
RETURNING id;
|
||||||
@@ -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 {
|
||||||
@@ -295,6 +361,18 @@ type CoreProjectInvitation struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreProjectLogin struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
App string `json:"app"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Ciphertext []byte `json:"ciphertext"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreProjectMember struct {
|
type CoreProjectMember struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -303,6 +381,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,158 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: project_logins.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteProjectLogin = `-- name: DeleteProjectLogin :one
|
||||||
|
DELETE FROM core.project_logins
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND app = $2
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteProjectLoginParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
App string `json:"app"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteProjectLogin(ctx context.Context, arg DeleteProjectLoginParams) (uuid.UUID, error) {
|
||||||
|
row := q.db.QueryRow(ctx, deleteProjectLogin, arg.ProjectID, arg.App)
|
||||||
|
var id uuid.UUID
|
||||||
|
err := row.Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProjectLogin = `-- name: GetProjectLogin :one
|
||||||
|
SELECT id, project_id, app, username, ciphertext, origin, notes, created_at, updated_at FROM core.project_logins
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND app = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetProjectLoginParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
App string `json:"app"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetProjectLogin(ctx context.Context, arg GetProjectLoginParams) (CoreProjectLogin, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getProjectLogin, arg.ProjectID, arg.App)
|
||||||
|
var i CoreProjectLogin
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.App,
|
||||||
|
&i.Username,
|
||||||
|
&i.Ciphertext,
|
||||||
|
&i.Origin,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listProjectLogins = `-- name: ListProjectLogins :many
|
||||||
|
SELECT id, project_id, app, username, origin, notes, created_at, updated_at
|
||||||
|
FROM core.project_logins
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY app ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListProjectLoginsRow struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
App string `json:"app"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListProjectLogins(ctx context.Context, projectID uuid.UUID) ([]ListProjectLoginsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listProjectLogins, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListProjectLoginsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListProjectLoginsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.App,
|
||||||
|
&i.Username,
|
||||||
|
&i.Origin,
|
||||||
|
&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 upsertProjectLogin = `-- name: UpsertProjectLogin :one
|
||||||
|
INSERT INTO core.project_logins (
|
||||||
|
project_id,
|
||||||
|
app,
|
||||||
|
username,
|
||||||
|
ciphertext,
|
||||||
|
origin,
|
||||||
|
notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (project_id, app) DO UPDATE SET
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
ciphertext = EXCLUDED.ciphertext,
|
||||||
|
origin = EXCLUDED.origin,
|
||||||
|
notes = EXCLUDED.notes,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, project_id, app, username, ciphertext, origin, notes, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertProjectLoginParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
App string `json:"app"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Ciphertext []byte `json:"ciphertext"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertProjectLogin(ctx context.Context, arg UpsertProjectLoginParams) (CoreProjectLogin, error) {
|
||||||
|
row := q.db.QueryRow(ctx, upsertProjectLogin,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.App,
|
||||||
|
arg.Username,
|
||||||
|
arg.Ciphertext,
|
||||||
|
arg.Origin,
|
||||||
|
arg.Notes,
|
||||||
|
)
|
||||||
|
var i CoreProjectLogin
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.App,
|
||||||
|
&i.Username,
|
||||||
|
&i.Ciphertext,
|
||||||
|
&i.Origin,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -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,45 @@ 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)
|
||||||
|
DeleteProjectLogin(ctx context.Context, arg DeleteProjectLoginParams) (uuid.UUID, 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 +70,36 @@ 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)
|
||||||
|
GetProjectLogin(ctx context.Context, arg GetProjectLoginParams) (CoreProjectLogin, 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 +108,72 @@ 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)
|
||||||
|
ListProjectLogins(ctx context.Context, projectID uuid.UUID) ([]ListProjectLoginsRow, 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)
|
||||||
|
UpsertProjectLogin(ctx context.Context, arg UpsertProjectLoginParams) (CoreProjectLogin, 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,31 @@ 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/logins", h.listProjectLogins)
|
||||||
|
api.PUT("/projects/:projectID/logins/:app", h.setProjectLogin)
|
||||||
|
api.DELETE("/projects/:projectID/logins/:app", h.deleteProjectLogin)
|
||||||
|
api.POST("/projects/:projectID/logins/:app/reveal", h.revealProjectLogin)
|
||||||
|
|
||||||
|
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 +169,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 +188,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 +914,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 +1135,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 +1284,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,77 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *HTTPHandler) loginError(c *gin.Context, err error) {
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "invalid app id") || strings.Contains(msg, "username is required") {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.secretError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listProjectLogins(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListProjectLogins(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.loginError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) setProjectLogin(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.SetProjectLoginInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.App = c.Param("app")
|
||||||
|
result, err := h.Platform.SetProjectLogin(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.loginError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteProjectLogin(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteProjectLogin(c.Request.Context(), actor, projectID, c.Param("app"), middleware.RequestIDFromContext(c)); err != nil {
|
||||||
|
h.loginError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) revealProjectLogin(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.RevealProjectLogin(c.Request.Context(), actor, projectID, c.Param("app"), middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.loginError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -43,12 +44,12 @@ func (m *Metrics) DecrementActive() {
|
|||||||
func (m *Metrics) GetStats() map[string]any {
|
func (m *Metrics) GetStats() map[string]any {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
avgResponseTime := int64(0)
|
avgResponseTime := int64(0)
|
||||||
if m.requestCount > 0 {
|
if m.requestCount > 0 {
|
||||||
avgResponseTime = m.totalResponseTime.Milliseconds() / m.requestCount
|
avgResponseTime = m.totalResponseTime.Milliseconds() / m.requestCount
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"total_requests": m.requestCount,
|
"total_requests": m.requestCount,
|
||||||
"total_errors": m.errorCount,
|
"total_errors": m.errorCount,
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Loaded 100 of 349 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user