37 Commits
Author SHA1 Message Date
Tomáš Dvořák 31bc23386b Merge pull request #15 from Dvorinka/release/v0.7.1
CI / Backend Tests (push) Canceled after 0s
CI / Frontend Tests (push) Canceled after 0s
CI / Build All Services (push) Canceled after 0s
CI / Docker Build (auth) (push) Canceled after 0s
CI / Docker Build (backend) (push) Canceled after 0s
CI / Docker Build (frontend) (push) Canceled after 0s
CI / Migration Up/Down (push) Canceled after 0s
CI / Compose E2E Smoke (push) Canceled after 0s
CI / Lint (push) Canceled after 0s
CI / Publish edge images (auth) (push) Canceled after 0s
CI / Publish edge images (backend) (push) Canceled after 0s
CI / Publish edge images (frontend) (push) Canceled after 0s
chore: release v0.7.1 + auto-release on merge to master
2026-09-23 11:13:20 +02:00
Tomas DvorakandDevin 112cf84362 chore: release v0.7.1, auto-release on merge
Release workflow now also fires on workflow_run when CI succeeds on
master; verify job skips unless package.json version lacks a matching
tag, so merges only release on a version bump. Tag pushes unchanged.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 11:12:24 +02:00
Tomáš Dvořák 61ede5ee99 Merge pull request #13 from Dvorinka/fix/selfhosted-email-optional
CI / Backend Tests (push) Canceled after 0s
CI / Frontend Tests (push) Canceled after 0s
CI / Build All Services (push) Canceled after 0s
CI / Docker Build (auth) (push) Canceled after 0s
CI / Docker Build (backend) (push) Canceled after 0s
CI / Docker Build (frontend) (push) Canceled after 0s
CI / Migration Up/Down (push) Canceled after 0s
CI / Compose E2E Smoke (push) Canceled after 0s
CI / Lint (push) Canceled after 0s
CI / Publish edge images (auth) (push) Canceled after 0s
CI / Publish edge images (backend) (push) Canceled after 0s
CI / Publish edge images (frontend) (push) Canceled after 0s
fix: self-hosted auth without mail transport, password-manager-friendly login
2026-09-22 19:50:30 +02:00
Tomas DvorakandDevin 2fbde04563 fix: don't require email infra on self-hosted, improve login form detection
- sendTransactionalEmail no-ops (with a warning) when no mail transport is
  configured instead of throwing — sign-up 500'd on SMTP-less instances
- sendVerificationEmail catches transport failures so a broken SMTP can't
  block account creation; verification stays optional (requireEmailVerification
  was never set)
- login form gains id/name attributes and label for/id pairing so password
  managers (Proton Pass et al.) detect the fields; email input uses
  autocomplete=username

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 19:46:14 +02:00
Tomáš Dvořák 9c118d41c2 Merge pull request #12 from Dvorinka/feat/landing-page
feat: add static landing page
2026-09-22 19:38:16 +02:00
Tomas DvorakandDevin c0c5e1cfcb chore: gitignore vercel link metadata in landing/
Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 18:19:40 +02:00
Tomas DvorakandDevin 28b10eee2b feat: add static landing page
Self-contained index.html under landing/ — no build step, deploys as a
plain static site (Vercel output dir = landing/). Uses the app's design
tokens (zinc dark, vermilion accent, Geist/Geist Mono vendored as woff2),
real screenshots from docs/, and the README feature/architecture facts.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 18:14:28 +02:00
Tomáš Dvořák 23868ef7c9 Merge pull request #11 from Dvorinka/fix/installer-strip-build-blocks
CI / Backend Tests (push) Canceled after 0s
CI / Frontend Tests (push) Canceled after 0s
CI / Build All Services (push) Canceled after 0s
CI / Docker Build (auth) (push) Canceled after 0s
CI / Docker Build (backend) (push) Canceled after 0s
CI / Docker Build (frontend) (push) Canceled after 0s
CI / Migration Up/Down (push) Canceled after 0s
CI / Compose E2E Smoke (push) Canceled after 0s
CI / Lint (push) Canceled after 0s
CI / Publish edge images (auth) (push) Canceled after 0s
CI / Publish edge images (backend) (push) Canceled after 0s
CI / Publish edge images (frontend) (push) Canceled after 0s
fix: strip build blocks in installer prebuilt path
2026-09-21 22:31:36 +02:00
Tomas DvorakandDevin 10c667dffd fix: strip build blocks in installer prebuilt path
The installer's GHCR path ships a compose file that still declares
build: contexts pointing at apps/*/Dockerfile, which it never
downloads. Any later `compose build` or `up --build` fails with
"lstat apps: no such file or directory". Delete the build blocks
after a successful prebuilt pull so the shipped file matches what
is actually runnable.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 22:25:32 +02:00
Tomáš Dvořák 11aa82b459 Merge pull request #10 from Dvorinka/release/v0.7.0
CI / Backend Tests (push) Canceled after 0s
CI / Frontend Tests (push) Canceled after 0s
CI / Build All Services (push) Canceled after 0s
CI / Docker Build (auth) (push) Canceled after 0s
CI / Docker Build (backend) (push) Canceled after 0s
CI / Docker Build (frontend) (push) Canceled after 0s
CI / Migration Up/Down (push) Canceled after 0s
CI / Compose E2E Smoke (push) Canceled after 0s
CI / Lint (push) Canceled after 0s
CI / Publish edge images (auth) (push) Canceled after 0s
CI / Publish edge images (backend) (push) Canceled after 0s
CI / Publish edge images (frontend) (push) Canceled after 0s
chore: release v0.7.0
2026-09-21 17:58:18 +02:00
Tomas DvorakandDevin 9658e167ba chore: release v0.7.0
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 17:53:47 +02:00
Tomáš Dvořák e71db32885 Merge pull request #9 from Dvorinka/feat/instance-admin
feat: instance administration, bootstrap admin, managed-tier OAuth gate
2026-09-21 17:53:03 +02:00
Tomas DvorakandDevin c6166de886 feat: instance administration, bootstrap admin, and managed-tier OAuth gate
Self-hosted instances are now email/password only — OAuth providers
(GitHub/Google/Discord/Microsoft) activate solely when PRIMORA_MANAGED=true,
reserved for the future hosted tier.

Sign-up lifecycle: the first public sign-up becomes the instance admin; after
bootstrap, sign-up closes by default. A better-auth databaseHook gates only
/sign-up/email, so admin-created users and OAuth flows bypass it. The login
page reads GET /instance/public to hide the sign-up tab, show the bootstrap
notice, and render only the providers the instance offers.

Instance settings live in core.settings (migration 00010) and resolve
in-app > env > built-in default, cached 15s. Platform admins (better-auth
user.role) manage sign-up, mail transport, and rate limits via
GET/PUT/DELETE /instance/settings or the new Settings → Instance tab. Secret
values are AES-256-GCM encrypted at rest with PRIMORA_ENCRYPTION_KEY, never
returned by the API, and decrypted by the auth service at send time.

Auth service reads the same settings table for the sign-up gate and mail
config, so in-app changes take effect without a restart.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 17:46:47 +02:00
Tomáš Dvořák 1214108d6b Merge pull request #8 from Dvorinka/docs/readme-cleanup
docs: slim README, move detail to per-app and contributor docs
2026-09-21 16:49:00 +02:00
Tomas DvorakandDevin 4f66c72121 docs: slim README, move detail into per-app and contributor docs
README down to the Containr skeleton: header, three-screenshot row
(rest behind <details>), features, architecture, quick start, links.
Dropped tables and sections already covered by QUICK_START.md.
Native dev commands and repo layout moved to CONTRIBUTING.md;
CLI/MCP usage moved to new per-app READMEs.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 16:46:32 +02:00
Tomáš Dvořák d3dc84028d Merge pull request #7 from Dvorinka/docs/readme-screenshots
docs: README refresh with logo and app screenshots
2026-09-21 16:19:12 +02:00
Tomas DvorakandDevin 591dedab2f docs: restructure README, add logo and app screenshots
Containr-style layout: centered mark, tagline, nav, CI/release/license
badges. New screenshots section with six captures from demo mode
(overview, storage, telemetry, collections, automation, audit).
Configuration and port tables added; existing install/dev/CLI content
preserved.

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 16:16:38 +02:00
Tomáš Dvořák 5af23fbdc8 Merge pull request #6 from Dvorinka/fix/release-upload-files
ci: upload release assets by file, not glob
2026-09-21 14:41:58 +02:00
Tomas DvorakandDevin bac8f08782 ci: upload release assets by file, not glob
Desktop artifacts extract to nested dirs (appimage/, deb/, rpm/);
dist-release/* expanded to directories and gh release create died on
"is a directory" mid-upload. find -type f builds the file list.

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

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

Authored By: TDvorak <[email protected]>

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

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

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

Authored By: TDvorak <[email protected]>

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

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

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

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

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

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

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

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

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

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

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

Authored By: TDvorak <[email protected]>

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-17 20:39:31 +02:00
Tomas DvorakandDevin 55edbf8b56 feat: phase 6 production hardening + DB-to-DB transfer
Security:
- Graceful shutdown: http.Server + signal.NotifyContext, 10s drain,
  retention sweeper cancelled before pool close
- Scoped API keys (migration 00006): ingest/read/write/admin enforced in
  auth middleware; scope picker + badges in Settings UI; scopeless legacy
  keys act as admin. Read-only keys now get 403 on ingest (verified live)
- nginx: baseline security headers on all responses, CSP scoped to SPA;
  fixed add_header inheritance shadowing
- better-auth rate limit enabled unconditionally (was gated on NODE_ENV,
  which the shipped .env sets to development)
- Mailpit moved to docker-compose.dev.yml; prod compose cannot expose it
- setup.sh generates PRIMORA_ENCRYPTION_KEY

Reliability/ops:
- Retention: per-project retention_{events,audit,webhook}_days
  (migration 00007, 0=off) + hourly RetentionSweeper; edit UI in Projects
- GET /api/v1/metrics Prometheus exposition
- compose: healthchecks + restart policies everywhere, dragonfly pinned
  to v2.0.0, depends_on service_healthy
- backup.sh/restore.sh (pg_dump -Fc + storage volume); restore verified
  against scratch postgres+volume
- .dockerignore: exclude target dirs (build context was 4.7GB)

Correctness/testing:
- DBX transfer endpoint: POST /db-connections/:id/transfer — SQL or Redis
  target, 1000-row bound, server-side creds (verified live pg→dragonfly)
- Pagination audit: ListComponents capped at 500 (auto-registers)
- CI: goose up/reset/up job + compose e2e-smoke job (smoke-e2e.sh
  verified locally: signup, bootstrap, scoped keys, ingest, metrics)
- k6 load sanity script: 6650 reqs, ~217/s, 0 real failures, p95 221ms
  (429s = configured rate limiter working)

Docs: DEPLOYMENT_GUIDE.md, PRODUCTION_READINESS.md; verify script
repaired (stale refs + set -e arithmetic abort). ROADMAP: phase 2 +
phase 6 marked complete.

Authored By: TDvorak <[email protected]>

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

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

No files matched your search

+9
View File
@@ -7,3 +7,12 @@ coverage
.env
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
+11
View File
@@ -29,6 +29,14 @@ BETTER_AUTH_SECRET=change-me-super-long-better-auth-secret
BETTER_AUTH_URL=http://localhost/auth
# Comma-separated emails promoted to auth admin on boot (Authentication → Users panel)
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
FRONTEND_PORT=3000
@@ -42,6 +50,8 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1
BACKEND_INTERNAL_URL=http://backend:8080
BACKEND_STORAGE_ROOT=/data/storage
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
# values win over these env vars; deleting the in-app value reverts here.
RESEND_API_KEY=
MAIL_FROM=Primora <[email protected]>
SMTP_HOST=mailpit
@@ -50,6 +60,7 @@ SMTP_USER=
SMTP_PASSWORD=
SMTP_SECURE=false
# OAuth keys only take effect when PRIMORA_MANAGED=true (managed tier).
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
+160 -21
View File
@@ -2,9 +2,12 @@ name: CI
on:
push:
branches: [main, develop]
branches: [master]
pull_request:
branches: [main, develop]
branches: [master]
permissions:
contents: read
jobs:
backend-tests:
@@ -96,38 +99,171 @@ jobs:
run: go build -v ./cmd/server
docker-build:
name: Docker Build Test
name: Docker Build (${{ matrix.app }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
app: [backend, auth, frontend]
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build backend image
- name: Build ${{ matrix.app }} image
uses: docker/build-push-action@v6
with:
context: .
file: apps/backend/Dockerfile
file: apps/${{ matrix.app }}/Dockerfile
push: false
tags: primora-backend:test
- name: Build auth image
load: true
tags: primora-${{ matrix.app }}:test
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
with:
context: .
file: apps/auth/Dockerfile
push: false
tags: primora-auth:test
- name: Build frontend image
uses: docker/build-push-action@v6
file: apps/${{ matrix.app }}/Dockerfile
push: true
tags: |
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:edge
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:sha-${{ github.sha }}
cache-from: type=gha,scope=${{ matrix.app }}
cache-to: type=gha,mode=max,scope=${{ matrix.app }}
migrations:
name: Migration Up/Down
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: primora_test
POSTGRES_PASSWORD: test
POSTGRES_DB: primora_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
context: .
file: apps/frontend/Dockerfile
push: false
tags: primora-frontend:test
go-version: '1.26'
cache-dependency-path: apps/backend/go.sum
- name: Install goose
run: go install github.com/pressly/goose/v3/cmd/[email protected]
- name: Migrations up
run: |
goose -dir apps/backend/db/migrations \
postgres "postgres://primora_test:test@localhost:5432/primora_test?sslmode=disable" up
- name: Migrations reset (all downs)
run: |
goose -dir apps/backend/db/migrations \
postgres "postgres://primora_test:test@localhost:5432/primora_test?sslmode=disable" reset
- name: Migrations up again (idempotent re-apply)
run: |
goose -dir apps/backend/db/migrations \
postgres "postgres://primora_test:test@localhost:5432/primora_test?sslmode=disable" up
e2e-smoke:
name: Compose E2E Smoke
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Prepare env
run: cp .env.example .env
- name: Validate compose files
run: >-
docker compose
-f docker-compose.yml -f docker-compose.dev.yml
config -q
- name: Start stack
run: >-
docker compose
-f docker-compose.yml -f docker-compose.dev.yml
up -d --build
- name: Run smoke checks
run: ./scripts/smoke-e2e.sh
- name: Dump logs on failure
if: failure()
run: >-
docker compose
-f docker-compose.yml -f docker-compose.dev.yml
logs --tail=200
- name: Tear down
if: always()
run: >-
docker compose
-f docker-compose.yml -f docker-compose.dev.yml
down -v
lint:
name: Lint
@@ -156,3 +292,6 @@ jobs:
with:
version: latest
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
+83 -14
View File
@@ -1,35 +1,67 @@
name: Release
# Triggered by semver tags: git tag v0.3.0 && git push --tags
# Tag must match the root package.json version.
# Triggered two ways:
# 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:
push:
tags: ["v*.*.*"]
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [master]
permissions:
contents: write
packages: write
env:
VERSION: ${{ github.ref_name }}
jobs:
verify:
name: Verify tag
name: Verify version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.check.outputs.version }}
needed: ${{ steps.check.outputs.needed }}
steps:
- 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: |
PKG=$(node -p "require('./package.json').version")
if [ "v$PKG" != "$VERSION" ]; then
echo "tag $VERSION does not match package.json version $PKG"
DESKTOP_PKG=$(node -p "require('./apps/desktop/package.json').version")
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
fi
images:
name: Build + push ${{ matrix.app }}
needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -37,7 +69,11 @@ jobs:
app: [backend, auth, frontend]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-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
with:
registry: ghcr.io
@@ -48,16 +84,18 @@ jobs:
with:
context: .
file: apps/${{ matrix.app }}/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/primora-${{ matrix.app }}:${{ env.VERSION }}
ghcr.io/${{ github.repository_owner }}/primora-${{ matrix.app }}:latest
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }}
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:latest
cache-from: type=gha,scope=${{ matrix.app }}
cache-to: type=gha,mode=max,scope=${{ matrix.app }}
cli-binaries:
name: Compile ${{ matrix.name }}
needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -87,6 +125,7 @@ jobs:
desktop:
name: Desktop bundle (${{ matrix.name }})
needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -123,6 +162,7 @@ jobs:
npm-packages:
name: Pack npm tarballs
needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -144,15 +184,39 @@ jobs:
release:
name: GitHub Release
needs: [images, cli-binaries, desktop, npm-packages]
needs: [verify, images, cli-binaries, desktop, npm-packages]
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.verify.outputs.version }}
steps:
- 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
with:
pattern: "*"
pattern: "binaries-*"
merge-multiple: true
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
run: |
awk -v ver="${VERSION#v}" '
@@ -164,7 +228,12 @@ jobs:
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release create "$VERSION" --title "$VERSION" --notes-file notes.md dist-release/*
# Desktop artifacts extract to nested dirs (appimage/, deb/, ...) —
# a bare glob hands gh a directory and the upload dies mid-flight.
run: |
mapfile -t files < <(find dist-release -type f)
[ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; }
gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}"
npm-publish:
name: Publish to npm
+2
View File
@@ -29,6 +29,8 @@ apps/desktop/src-tauri/gen/schemas
# Runtime data
infra/data
backups
.restore-tmp.*
infra/postgres
infra/dragonfly
infra/mailpit
+58 -1
View File
@@ -2,7 +2,64 @@
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
## [Unreleased]
## [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
+35 -3
View File
@@ -9,9 +9,24 @@ cp .env.example .env
docker compose up -d --build
```
For native development run only the infrastructure in Docker
(`docker compose up -d postgres dragonfly mailpit`) and start the three
services yourself.
For native development run only the infrastructure in Docker and start the
three 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
@@ -29,6 +44,23 @@ services yourself.
If you changed `apps/backend/openapi/openapi.yaml`, run
`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
- **Generated code is never hand-edited** — `packages/api-client/src/generated`
+151
View File
@@ -0,0 +1,151 @@
# Primora Deployment Guide
Primora ships as a Docker Compose stack: nginx (edge), frontend (static SPA),
auth (Better Auth / Hono), backend (Go API), postgres, and dragonfly
(Redis-compatible cache). The base `docker-compose.yml` is the production
surface — dev-only services like Mailpit live in `docker-compose.dev.yml`.
## Prerequisites
- Docker Engine 24+ with the Compose plugin
- A host with 2 GB free RAM minimum (postgres + dragonfly + services)
- A domain pointing at the host if you want TLS
## Quick start
```bash
git clone <repo> && cd Primora
./scripts/setup.sh # writes .env with generated secrets
docker compose up -d # production surface only
```
For local development (includes Mailpit at `/mailpit/`):
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```
`setup.sh` generates `JWT_SECRET`, `BETTER_AUTH_SECRET`, and
`PRIMORA_ENCRYPTION_KEY`. Do not reuse the example values anywhere public.
## Environment reference
| Variable | Purpose | Required |
|---|---|---|
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | database credentials | yes |
| `DATABASE_URL` | backend → postgres DSN | yes |
| `DRAGONFLY_URL` | backend → cache DSN | yes |
| `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes |
| `BETTER_AUTH_SECRET` | auth session/signing secret | yes |
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for integration credentials + webhook secrets at rest | production |
| `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container | yes |
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
| `RESEND_API_KEY` | alternative to SMTP — Resend API | no |
| `*_CLIENT_ID` / `*_CLIENT_SECRET` | GitHub / Google / Discord / Microsoft OAuth | no |
| `NGINX_PORT` | host port for the edge (default 80) | no |
## TLS
Primora does not terminate TLS itself — put a reverse proxy in front of
nginx's port 80. The nginx config sets `X-Forwarded-*`-aware headers; pass
them through.
**Caddy** (`Caddyfile`):
```
primora.example.com {
reverse_proxy localhost:80
}
```
**Traefik** (compose labels on the nginx service):
```yaml
labels:
- traefik.enable=true
- traefik.http.routers.primora.rule=Host(`primora.example.com`)
- traefik.http.routers.primora.entrypoints=websecure
- traefik.http.routers.primora.tls.certresolver=letsencrypt
```
Once TLS is live, update `VITE_APP_URL`, `VITE_AUTH_BASE_URL`,
`AUTH_BASE_URL`, `BETTER_AUTH_URL`, and `COOKIE_DOMAIN` to the https origin
and rebuild the frontend image (`docker compose build frontend`).
## SMTP / email
Auth sends verification and password-reset email through SMTP
(`SMTP_*` + `MAIL_FROM`) or Resend (`RESEND_API_KEY`). In development the
`docker-compose.dev.yml` overlay runs Mailpit; mail is captured at
`http://localhost/mailpit/` and nothing leaves the host. **The production
compose file contains no mail sink — configure real SMTP before inviting
users.**
## Backups
`scripts/backup.sh` produces a tarball with a `pg_dump -Fc` of the database
and a tar of the `backend_storage` volume:
```bash
./scripts/backup.sh # → backups/primora-<stamp>.tar.gz
```
Restore (destructive — stops writes):
```bash
docker compose stop backend auth
./scripts/restore.sh backups/primora-<stamp>.tar.gz
docker compose up -d
```
Cron example (nightly, keep 14):
```cron
17 3 * * * cd /opt/primora && ./scripts/backup.sh && \
ls -1t backups/primora-*.tar.gz | tail -n +15 | xargs -r rm
```
systemd timer example is in `PRODUCTION_READINESS.md`.
## Upgrades
Migrations run automatically on backend boot (goose). Safe order:
```bash
./scripts/backup.sh # 1. snapshot first
docker compose pull && docker compose build # 2. new images
docker compose up -d # 3. rolling recreate; backend migrates on boot
./scripts/smoke-e2e.sh # 4. verify health, auth, ingest
```
`goose` migrations include `Down` sections; `goose -dir apps/backend/db/migrations postgres "$DATABASE_URL" down` rolls back one version if a release misbehaves.
## Storage constraint
Object storage is the **local filesystem** (`backend_storage` volume). There
is no S3-compatible backend yet — the volume must be on durable, backed-up
disk, and a single host serves all objects. Plan capacity accordingly;
multi-node deployments need an external object store first.
## Observability
- `GET /api/v1/metrics` — Prometheus exposition (unauthenticated; restrict at
the edge if the instance is public — see the commented rule in
`infra/nginx/default.conf`).
- `GET /api/v1/health/liveness` / `readiness` — compose healthchecks use the
same endpoints.
- Structured JSON logs on stdout for every service — ship with your normal
docker log driver.
## API key scopes
Keys are created with scopes: `ingest`, `read`, `write`, `admin`.
`admin` unlocks everything; `write` unlocks read + ingest + non-admin
mutations; `read`/`ingest` are single-purpose. Keys without stored scopes
(pre-scope keys) behave as `admin` for backwards compatibility — rotate them
into scoped keys at your leisure.
+85
View File
@@ -0,0 +1,85 @@
# Primora Production Readiness
Status checklist for self-hosted production use. Everything listed under
"Hardened" is implemented and verified; "Known limits" is the honest
remainder.
## Hardened
- **Graceful shutdown** — the backend drains in-flight requests for up to
10 s on SIGTERM/SIGINT (`internal/app`).
- **Scoped API keys** — `ingest` / `read` / `write` / `admin`, enforced in
auth middleware before handlers run. Legacy keys without stored scopes act
as `admin`.
- **Brute-force protection** — Better Auth rate limiting is enabled
unconditionally (3 attempts / 10 s on sign-in, sign-up, password change);
an IP-level limiter caps `/auth/*` at 60 req/min, and per-identity API
rate limits apply via `USER_RATE_LIMIT_PER_MINUTE` /
`API_KEY_RATE_LIMIT_PER_MINUTE` in Dragonfly.
- **Metrics** — `GET /api/v1/metrics` in Prometheus text format.
- **Retention** — per-project sweeper for telemetry events, audit log rows,
and webhook deliveries (`retention_*_days`, 0 = disabled, default 90).
- **Edge security** — nginx emits `X-Frame-Options`, `X-Content-Type-Options`,
`Referrer-Policy`, `Permissions-Policy`, and a CSP scoped to the SPA only.
- **Compose hygiene** — all services have healthchecks and
`restart: unless-stopped`; Dragonfly is pinned to `v2.0.0`; Mailpit lives
only in `docker-compose.dev.yml`.
- **Secrets** — `setup.sh` generates `JWT_SECRET`, `BETTER_AUTH_SECRET`, and
`PRIMORA_ENCRYPTION_KEY`; credentials and webhook secrets are AES-256
encrypted at rest.
- **Release images** — GitHub release builds `linux/amd64` + `linux/arm64`
via buildx/QEMU.
- **CI coverage** — Go tests, frontend tests + typecheck, all three Docker
builds, golangci-lint, migration up/reset/up round-trip, and a compose E2E
smoke (`scripts/smoke-e2e.sh`).
- **Backups** — `scripts/backup.sh` / `scripts/restore.sh` cover the database
(custom-format dump) and the storage volume.
## Known limits
- **Storage is local filesystem only.** No S3 backend — the
`backend_storage` volume is the durability boundary.
- **Single host.** Compose topology is single-node; postgres and dragonfly
are not clustered. HA is out of scope for v0.x.
- **No TLS inside the stack.** Terminate at a front proxy (Caddy/Traefik);
see DEPLOYMENT_GUIDE.md.
- **Metrics are in-memory counters.** `/api/v1/metrics` covers request
totals/errors/duration — not per-route histograms or DB pool stats yet.
- **`/api/v1/metrics` is unauthenticated.** Scrape-only design; block it at
the edge for public instances (commented rule in `infra/nginx/default.conf`).
## systemd timer for backups
`/etc/systemd/system/primora-backup.service`:
```ini
[Unit]
Description=Primora backup
[Service]
Type=oneshot
WorkingDirectory=/opt/primora
ExecStart=/opt/primora/scripts/backup.sh
```
`/etc/systemd/system/primora-backup.timer`:
```ini
[Unit]
Description=Nightly Primora backup
[Timer]
OnCalendar=*-*-* 03:17:00
Persistent=true
[Install]
WantedBy=timers.target
```
## Verification
```bash
./scripts/verify-production-ready.sh # structural check
./scripts/smoke-e2e.sh # live E2E against a running stack
npm run check # typecheck + tests + generated-code drift
```
+24 -2
View File
@@ -61,10 +61,32 @@ The important ones:
| `POSTGRES_*` / `DATABASE_URL` | Postgres connection |
| `DRAGONFLY_URL` | Cache/rate-limit store |
| `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 |
| `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 |
| `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
```bash
+87 -98
View File
@@ -1,6 +1,60 @@
# 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 |
|:-:|:-:|:-:|
| ![Project overview](docs/screenshots/overview.png) | ![Object storage](docs/screenshots/storage.png) | ![Telemetry and component health](docs/screenshots/telemetry.png) |
<details>
<summary>More screenshots</summary>
| Collections | Automation | Audit log |
|:-:|:-:|:-:|
| ![JSON document collections](docs/screenshots/collections.png) | ![Scheduled jobs and live events](docs/screenshots/automation.png) | ![Audit log with export](docs/screenshots/audit.png) |
</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 your local filesystem, public or private.
- **Collections** - schema-flexible JSON documents in Postgres JSONB.
- **API keys** - `prm_<prefix>_<secret>` credentials, shown once.
- **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)
@@ -10,116 +64,51 @@ Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite)
──▶ Local filesystem object storage
```
## What you get
- **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)
## Quick Start
Prerequisites: Docker with the Compose plugin.
```bash
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash
```
Installs into `./primora`, generates secrets, and starts the stack on port 80.
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
```
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
cp .env.example .env # fill JWT_SECRET and BETTER_AUTH_SECRET
docker compose up -d --build
```
## Configuration
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
| Check | Endpoint |
|---|---|
| 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
```
- **[CLI](apps/cli)** - `primora` for orgs, projects, buckets, objects, keys, and audit from the terminal.
- **[MCP server](apps/mcp)** - exposes `primora_*` tools to MCP clients.
- **[Desktop](apps/desktop)** - Tauri 2 shell connecting to your deployment, with tray presence.
- **PWA** - the dashboard is installable on mobile and desktop browsers.
## Documentation
- [QUICK_START.md](QUICK_START.md) — setup, configuration, troubleshooting
- [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
- [QUICK_START.md](QUICK_START.md) - setup, configuration, troubleshooting
- [project_backend.md](project_backend.md) / [project_frontend.md](project_frontend.md) - original design specs
- `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
+182 -10
View File
@@ -17,7 +17,7 @@ Design docs: [project_backend.md](project_backend.md) · [project_frontend.md](p
- Docker Compose stack, nginx proxy, OpenAPI-generated client
- 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.
@@ -39,7 +39,7 @@ Everything an operator touches that isn't in the box yet.
- **Responsive pass** — sidebar already collapses; audit every page at
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
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
auto-register as connections on every project, so "put Postgres in it and
it runs Postgres" works out of the box.
- **DB-to-DB links** — DBX data transfer between two saved connections
(e.g. Postgres → Dragonfly cache warm), plus documented patterns for
pairing them.
- **DB-to-DB links** ✅ — `POST …/db-connections/:id/transfer` runs a query
on the source connection and writes rows to a SQL target (`target_table`)
or a Redis target (`key_pattern` + `value_column`, optional TTL). Bounded
at 1000 rows; credentials stay server-side.
## Phase 3 — Observability (IMS merge) ✅
@@ -111,18 +112,189 @@ IMS (`~/Desktop/PROG+HTML/IMS`) is discontinued and folded in — same stack
in-app install banner on `beforeinstallprompt`. Native shells via Capacitor
remain deferred until push notifications are a real requirement.
- ~~**Versioning**~~ — `v0.x` minor-per-phase until the API surface stabilizes.
Release pipeline (Phase 1) now also ships desktop bundles: tag → GitHub
Release notes + ghcr.io images + CLI/MCP binaries + desktop installers.
`v0.5.0` tagged and pushed; the release pipeline now also ships desktop
bundles: tag → GitHub Release notes + ghcr.io images + CLI/MCP binaries +
desktop installers.
- **Docs site** — deferred on purpose. README stays canonical; the OpenAPI
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.
Follow-ups:
- Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run
(host-verified on Linux only).
- Desktop bundle signing + auto-updater — unsigned artifacts today; add when
distribution matures.
## Phase 6 — Production hardening (v0.6.0) ✅
The v1 feature surface is essentially complete; what remains is the
difference between "works" and "safe to run other people's data". Roughly
three-quarters of the distance is travelled — this phase is the rest.
Foundations already in place: liveness/readiness probes with dependency
checks, per-key and per-user rate limiting, CORS locked to `PUBLIC_URL`,
AES-256-GCM credential encryption (`PRIMORA_ENCRYPTION_KEY` enforced in
production), audit log on mutations, org/project roles, SMTP + Resend in
the auth service, migrations on boot, single-command compose deploy,
release pipeline with images + binaries + desktop bundles.
### Security
- ~~**Graceful shutdown**~~ ✅ — `http.Server` + `signal.NotifyContext`,
10 s drain; verified via SIGTERM on the running container.
- ~~**Security headers**~~ ✅ — `X-Frame-Options`, `X-Content-Type-Options`,
`Referrer-Policy`, `Permissions-Policy` on every response; CSP scoped to
the SPA location only (verified live).
- ~~**TLS story**~~ ✅ — documented Caddy/Traefik front-proxy setups in
DEPLOYMENT_GUIDE.md; no embedded cert manager.
- ~~**Scoped API keys**~~ ✅ — `scopes` column (migration 00006), enforced
in auth middleware (`requiredAPIKeyScope`), UI checkboxes in Settings;
keys without stored scopes behave as `admin` (legacy). Verified live: a
`read`-only key gets 403 on ingest.
- ~~**Dev-surface isolation**~~ ✅ — Mailpit moved to
`docker-compose.dev.yml`; the base compose file cannot expose it.
- ~~**Auth brute-force**~~ ✅ — Better Auth's built-in rate limiting is now
enabled unconditionally (it previously followed `NODE_ENV`, which the
shipped `.env` sets to development); the IP-level 60 req/min limiter on
`/auth/*` stays in front.
- ~~**Setup ergonomics**~~ ✅ — `setup.sh` generates
`PRIMORA_ENCRYPTION_KEY` via `openssl rand -hex 32` alongside the other
secrets.
### Reliability & operations
- ~~**Backups + restore runbook**~~ ✅ — `scripts/backup.sh` (pg_dump -Fc +
storage volume tar) and `scripts/restore.sh`; restore verified against a
scratch postgres + volume. Cron/systemd examples in the docs.
- ~~**Retention**~~ ✅ — per-project `retention_{events,audit,webhook}_days`
(migration 00007, 0 = disabled) + hourly `RetentionSweeper`; settings
exposed in the project edit dialog.
- ~~**Platform metrics export**~~ ✅ — `GET /api/v1/metrics` renders
Prometheus text exposition (requests, errors, active, duration sum).
- ~~**Production compose profile**~~ ✅ — healthchecks + `restart:
unless-stopped` on every service, `depends_on: service_healthy`,
Dragonfly pinned to `v2.0.0`.
- ~~**Upgrade runbook**~~ ✅ — DEPLOYMENT_GUIDE.md documents backup → pull →
migrate-on-boot → verify, plus rollback via `goose down`.
### Correctness
- ~~**Pagination audit**~~ ✅ — every `:many` query audited; time-series and
entity lists are naturally bounded; `ListComponents` capped at 500
(auto-registers from ingest).
- ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres →
dragonfly transfer, key templating, TTL).
- ~~**External object storage**~~ ✅ — constraint documented in
DEPLOYMENT_GUIDE.md / PRODUCTION_READINESS.md; S3 backend deferred.
### Testing
- ~~**E2E smoke in CI**~~ ✅ — `scripts/smoke-e2e.sh` + `e2e-smoke` job:
health, signup, token, bootstrap/org+project, scoped key create, ingest,
read-only key rejection, metrics. Verified locally against the stack.
- ~~**Migration tests**~~ ✅ — `migrations` job runs goose up → reset → up
against scratch postgres; verified locally.
- ~~**Load sanity**~~ ✅ — `scripts/load-sanity.js` (k6): ingest + events
read, 15 VUs / 30 s. Local run: 6650 requests, ~217 req/s, 0 real
failures, p95 221 ms — most requests beyond the configured 600/min cap
correctly returned 429.
### Distribution
- ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via
QEMU + buildx.
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle
verification, signing + auto-updater.
- Optional: SBOM + provenance attestation on release images.
### Docs
- ~~**Self-host guide**~~ ✅ — `DEPLOYMENT_GUIDE.md` (TLS, SMTP, backups,
upgrades, env reference, storage constraint) and
`PRODUCTION_READINESS.md` written; `verify-production-ready.sh` updated
to check them plus the Phase 6 artifacts.
**v1.0 bar:** every Security item done, a restore test actually run, E2E
smoke green in CI, and the upgrade runbook followed once on a real
upgrade. Docs site revisited here, not before.
## Phase 7 — Automation & realtime (v0.7.0) ✅
The last Supabase/Appwrite-parity gaps: scheduled work and a live view of
what the platform is doing. One event fan-out drives webhooks and the
dashboard's live feed off the same stream.
- **Scheduled jobs** — `core.scheduled_jobs` + `core.scheduled_job_runs`
(migration 00009). Cron expressions and descriptors (`@hourly`,
`@daily`, `@every 30m`, `*/15 * * * *`) via robfig/cron. In-process
`JobScheduler`: 15 s due-scan, buffered execution queue, missed
schedules collapse into one run, run history pruned by retention.
- **Signed delivery** — jobs POST a JSON envelope (event, project/job/run
IDs, trigger, timestamp, static payload) with
`X-Primora-Signature: sha256=<hmac-sha256(body)>`, 10 s timeout. Secrets
are AES-256-GCM sealed at rest and the generated value is returned
exactly once (`has_secret` thereafter).
- **Run history** — status (running/success/failed), trigger source,
HTTP status, duration, error text, timestamps; `POST …/jobs/:id/run`
for manual execution. Role-gated: viewers read, admin/developer write.
- **Domain events** — `publishEvent` fans document/object/issue/deploy/
job mutations out to realtime subscribers and matching webhooks.
Webhook event filter extended to `job.run`, `document.*`, `object.*`.
- **Realtime stream** — `GET …/projects/:projectID/realtime/stream` SSE,
25 s keepalives, `?token=`/`?api_key=` query auth for `EventSource`.
- **Automation page** — Schedules tab (table, create modal, run history
expansion, manual run, enable/disable, delete, secret-shown-once) and
Live events tab (live badge, pause, clear, reconnect). Demo-mode seeds
and stubs included; command palette entry added.
- **Surfaces** — `primora jobs:*` CLI group (list/create/run/runs/rm),
six new `primora_*_job*` MCP tools, `AutomationService` in the
generated client.
### Fixes folded in
- **gzip swallowed SSE** — the compression middleware buffered every
flush, so `EventSource` saw headers then nothing and died. `*/stream`
paths now bypass compression (telemetry stream was equally broken).
- **Trusted origins ignored `PUBLIC_URL`** — compose hardcoded
`http://localhost` for `BETTER_AUTH_URL`/`AUTH_BASE_URL`/`VITE_APP_URL`;
they now read env so a non-default port/domain actually works.
- **Relative auth base URL** — better-auth rejects relative URLs;
`auth-client` now resolves `/auth` against `window.location.origin`,
preserving same-origin defaults without breaking split-origin dev.
## Future phases — candidates
Ordered loosely by leverage. None committed; each gets scoped when picked.
- **Phase 8 — Client realtime SDK** — `@primora/client` channel
subscriptions (`channel('documents').on('created', …)`), presence,
and broadcast over the Phase 7 stream. Supabase-realtime parity is a
protocol question, not a transport one.
- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers.
Deno or Bun isolates in a sidecar; secrets via env, payload via stdin.
The job runner already supplies the scheduling half.
- **Phase 10 — Storage backends** — S3-compatible object storage behind
the existing bucket abstraction (deferred from Phase 6), plus
presigned-URL uploads for large files.
- **Phase 11 — Generated REST per collection** — PostgREST-style
auto-CRUD + filtering over Collections documents; a clean read layer
on data that already exists.
- **Notifications** — alert rules on telemetry (error-rate thresholds,
heartbeat silence) fanning out through the webhook dispatcher.
- **Inbound webhooks** — project-scoped ingest endpoints that turn
external events into domain events/jobs.
- **HA mode** — leader-elected scheduler + distributed locks so two
backend replicas don't double-fire jobs; Postgres advisory locks are
enough at this scale.
- **Email surface** — transactional templates + send log in the
dashboard, backed by the auth service's existing SMTP/Resend wiring.
---
## What is deliberately out of scope
- Managed/hosted tier — the product is the self-hosted thing.
- Realtime subscriptions, edge functions, generated REST/GraphQL per-table
(Supabase PostgREST-style). Revisit after Phase 3 if wanted — Collections
already cover the common case.
- Per-request compute billing / hosted function runtime — if the
Functions phase lands it stays user-managed infrastructure.
- Embedding DBX's desktop UI — we use its MCP server as a library, not its app.
-28
View File
@@ -1,28 +0,0 @@
# Primora — Working list
## Phase 5 — Distribution (shipped, `deb56d9`)
- [x] PWA: vite-plugin-pwa + manifest + icons + install banner; SW precaches
the shell, `/api` `/auth` `/mailpit` stay NetworkOnly
- [x] `apps/desktop`: Tauri 2 thin shell — connect screen, tray
(Show / Change Server / Quit), close-to-tray, single-instance
- [x] `release.yml`: desktop bundle matrix (linux/macOS/windows) attached to
GitHub Releases
- [x] Workspace bumped to `0.5.0`, lockfile synced
- [x] CHANGELOG + ROADMAP + README updated
- [x] Gates: backend tests, frontend typecheck, workspace build, cargo check,
`check:generated` — all green
- [x] Smoke: desktop shell rendered the compose deployment's dashboard
(tauri-driver + WebKitWebDriver)
## Up next / future
- [x] Tag `v0.5.0` + push — release workflow fires on tag push
- [ ] Verify macOS/Windows desktop bundles from CI artifacts once the tag run
lands (host-verified on Linux only)
- [ ] Capacitor native shells — only if push notifications become a real
requirement
- [ ] Docs site / `docs/` folder — once the API surface stops moving per-phase
(revisit at `v1.0.0`)
- [ ] Desktop bundle signing + auto-updater — unsigned artifacts today; add
when distribution matures
+2 -1
View File
@@ -5,7 +5,8 @@ import { requestId } from "hono/request-id";
import { HTTPException } from "hono/http-exception";
import { createClient } from "redis";
import { auth, authPool, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js";
import { auth, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js";
import { authPool } from "./lib/db.js";
import { env } from "./lib/env.js";
const redisClient = createClient({
+85 -46
View File
@@ -1,50 +1,53 @@
import { betterAuth } from "better-auth";
import { APIError } from "better-auth/api";
import { admin, jwt } from "better-auth/plugins";
import { getMigrations } from "better-auth/db/migration";
import { Pool } from "pg";
import { authPool } from "./db.js";
import { sendTransactionalEmail } from "./mail.js";
import { getBoolSetting } from "./settings.js";
import { env } from "./env.js";
export const authPool = new Pool({
connectionString: env.DATABASE_URL,
});
const socialProviders = {
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
? {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
},
}
: {}),
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
? {
discord: {
clientId: env.DISCORD_CLIENT_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
},
}
: {}),
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
? {
microsoft: {
clientId: env.MICROSOFT_CLIENT_ID,
clientSecret: env.MICROSOFT_CLIENT_SECRET,
tenantId: env.MICROSOFT_TENANT_ID,
},
}
: {}),
};
// OAuth providers exist only on the managed tier (PRIMORA_MANAGED=true).
// Self-hosted instances are email/password only — the env keys are ignored
// unless the managed flag is set.
const socialProviders = env.PRIMORA_MANAGED
? {
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
? {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
},
}
: {}),
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
? {
discord: {
clientId: env.DISCORD_CLIENT_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
},
}
: {}),
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
? {
microsoft: {
clientId: env.MICROSOFT_CLIENT_ID,
clientSecret: env.MICROSOFT_CLIENT_SECRET,
tenantId: env.MICROSOFT_TENANT_ID,
},
}
: {}),
}
: {};
export const auth = betterAuth({
appName: "Primora",
@@ -63,18 +66,54 @@ 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) {
if (ctx?.path !== "/sign-up/email") return;
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: { ...user, 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.",
});
}
},
},
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
async sendVerificationEmail({ user, url }) {
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>`,
});
// Verification is optional — a misconfigured transport must not fail
// 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,
plugins: [
jwt({
+9
View File
@@ -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,
});
+12
View File
@@ -18,6 +18,15 @@ const envSchema = z.object({
SMTP_PORT: z.string().default("1025"),
SMTP_USER: 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_SECRET: 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),
SMTP_PORT: Number(parsed.data.SMTP_PORT),
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",
};
+40 -23
View File
@@ -2,23 +2,33 @@ import nodemailer from "nodemailer";
import { Resend } from "resend";
import { env } from "./env.js";
import { getStringSetting } from "./settings.js";
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null;
const transporter =
!resend && env.SMTP_HOST
? nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: false,
auth: env.SMTP_USER
? {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD,
}
: undefined,
})
: 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;
}
export async function sendTransactionalEmail(input: {
to: string;
@@ -26,9 +36,12 @@ export async function sendTransactionalEmail(input: {
text: 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({
from: env.MAIL_FROM,
from: cfg.from,
to: [input.to],
subject: input.subject,
text: input.text,
@@ -37,16 +50,20 @@ export async function sendTransactionalEmail(input: {
return;
}
if (!transporter) {
throw new Error("No mail transport configured for auth service.");
if (!("smtp" in cfg) || !cfg.smtp) {
// 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({
from: env.MAIL_FROM,
await nodemailer.createTransport(cfg.smtp).sendMail({
from: cfg.from,
to: input.to,
subject: input.subject,
text: input.text,
html: input.html,
});
}
+66
View File
@@ -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)");
}
@@ -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;
+2 -1
View File
@@ -4,8 +4,9 @@ INSERT INTO core.api_keys (
name,
prefix,
secret_hash,
scopes,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5)
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: ListAPIKeysForProject :many
+88
View File
@@ -0,0 +1,88 @@
-- name: ListScheduledJobs :many
SELECT * FROM core.scheduled_jobs
WHERE project_id = $1
ORDER BY created_at ASC;
-- name: GetScheduledJobByID :one
SELECT * FROM core.scheduled_jobs
WHERE id = $1;
-- name: CreateScheduledJob :one
INSERT INTO core.scheduled_jobs (
project_id,
name,
schedule,
url,
secret,
payload,
enabled,
next_run_at,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *;
-- name: UpdateScheduledJob :one
UPDATE core.scheduled_jobs
SET name = $3,
schedule = $4,
url = $5,
enabled = $6,
next_run_at = $7,
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
updated_at = NOW()
WHERE id = $1
AND project_id = $2
RETURNING *;
-- name: DeleteScheduledJob :one
DELETE FROM core.scheduled_jobs
WHERE id = $1
AND project_id = $2
RETURNING id;
-- name: ListDueScheduledJobs :many
SELECT * FROM core.scheduled_jobs
WHERE enabled
AND next_run_at IS NOT NULL
AND next_run_at <= $1;
-- name: MarkScheduledJobRan :one
UPDATE core.scheduled_jobs
SET last_run_at = $2,
last_status = $3,
next_run_at = $4,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: InsertScheduledJobRun :one
INSERT INTO core.scheduled_job_runs (job_id, triggered_by)
VALUES ($1, $2)
RETURNING *;
-- name: FinishScheduledJobRun :one
UPDATE core.scheduled_job_runs
SET status = $2,
status_code = $3,
error = $4,
duration_ms = $5,
finished_at = NOW()
WHERE id = $1
RETURNING *;
-- name: ListScheduledJobRuns :many
SELECT * FROM core.scheduled_job_runs
WHERE job_id = $1
ORDER BY created_at DESC
LIMIT $2;
-- name: PruneScheduledJobRuns :exec
DELETE FROM core.scheduled_job_runs r
WHERE r.job_id = $1
AND r.id NOT IN (
SELECT k.id FROM core.scheduled_job_runs k
WHERE k.job_id = $1
ORDER BY k.created_at DESC
LIMIT $2
);
+4 -1
View File
@@ -28,7 +28,10 @@ RETURNING *;
UPDATE core.projects
SET slug = $2,
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
RETURNING *;
+33
View File
@@ -0,0 +1,33 @@
-- Per-project retention sweeps. A retention of 0 days disables the sweep for
-- that stream. Pending webhook deliveries are never swept — they may still be
-- retrying.
-- name: SweepExpiredEvents :execrows
DELETE FROM core.events e
USING core.projects p
WHERE e.project_id = p.id
AND p.retention_events_days > 0
AND e.ts < NOW() - make_interval(days => p.retention_events_days);
-- name: SweepExpiredAuditLogs :execrows
DELETE FROM core.audit_logs a
USING core.projects p
WHERE a.project_id = p.id
AND p.retention_audit_days > 0
AND a.created_at < NOW() - make_interval(days => p.retention_audit_days);
-- name: SweepExpiredOrgAuditLogs :execrows
-- Org-scoped audit rows (project_id NULL) have no project retention — sweep
-- them on a fixed 365-day floor so they cannot grow unbounded.
DELETE FROM core.audit_logs a
WHERE a.project_id IS NULL
AND a.created_at < NOW() - INTERVAL '365 days';
-- name: SweepExpiredWebhookDeliveries :execrows
DELETE FROM core.webhook_deliveries d
USING core.webhooks w
JOIN core.projects p ON p.id = w.project_id
WHERE d.webhook_id = w.id
AND p.retention_webhook_days > 0
AND d.status <> 'pending'
AND d.created_at < NOW() - make_interval(days => p.retention_webhook_days);
+18
View File
@@ -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;
+2 -1
View File
@@ -11,7 +11,8 @@ WHERE project_id = $1 AND name = $2;
-- name: ListComponents :many
SELECT * FROM core.components
WHERE project_id = $1
ORDER BY name;
ORDER BY name
LIMIT 500;
-- name: DeleteComponent :exec
DELETE FROM core.components
+1
View File
@@ -14,6 +14,7 @@ require (
github.com/pressly/goose/v3 v3.26.0
github.com/redis/go-redis/v9 v9.16.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/stretchr/testify v1.11.1
)
+2
View File
@@ -104,6 +104,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/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/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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+51 -41
View File
@@ -2,12 +2,13 @@ package app
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"net/http"
"os"
"strconv"
"strings"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
@@ -37,6 +38,8 @@ type App struct {
Redis *redis.Client
DBX *dbx.Client
Platform *services.PlatformService
sweepCancel context.CancelFunc
}
func Bootstrap(ctx context.Context) (*App, error) {
@@ -88,7 +91,8 @@ func Bootstrap(ctx context.Context) (*App, error) {
if err != nil {
return nil, fmt.Errorf("encryption key: %w", err)
}
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, firstPartyDBSeeds(cfg), encryptor, logger)
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger)
settings := services.NewSettingsService(repo, encryptor)
if cfg.Env == "production" {
gin.SetMode(gin.ReleaseMode)
@@ -123,10 +127,12 @@ func Bootstrap(ctx context.Context) (*App, error) {
APIKeyPerMinute: cfg.APIKeyRateLimitPerMin,
UserPerMinute: cfg.UserRateLimitPerMin,
},
RateLimitResolver: settings.RateLimitPerMinute,
}.ResolveActor())
handler := &handlers.HTTPHandler{
Platform: platform,
Settings: settings,
Validate: validator.New(),
Metrics: metrics,
Readiness: func(c *gin.Context) map[string]any {
@@ -157,55 +163,59 @@ func Bootstrap(ctx context.Context) (*App, error) {
}
handler.Register(router)
sweepCtx, sweepCancel := context.WithCancel(context.Background())
sweeper := services.NewRetentionSweeper(repo, logger)
go sweeper.Run(sweepCtx)
return &App{
Config: cfg,
Router: router,
Logger: logger,
DB: dbPool,
Redis: redisClient,
DBX: dbxClient,
Platform: platform,
Config: cfg,
Router: router,
Logger: logger,
DB: dbPool,
Redis: redisClient,
DBX: dbxClient,
Platform: platform,
sweepCancel: sweepCancel,
}, nil
}
func (a *App) Run() error {
address := ":" + a.Config.ServerPort
a.Logger.Info("primora backend starting", "address", address)
return a.Router.Run(address)
}
srv := &http.Server{Addr: address, Handler: a.Router}
// firstPartyDBSeeds turns the platform's own Postgres and Dragonfly URLs into
// managed connection seeds registered on every project.
func firstPartyDBSeeds(cfg config.Config) []services.ManagedDBSeed {
var seeds []services.ManagedDBSeed
if u, err := url.Parse(cfg.DatabaseURL); err == nil && u.Hostname() != "" {
cfg2 := services.DBConnectionConfig{
Host: u.Hostname(),
Database: strings.TrimPrefix(u.Path, "/"),
Username: u.User.Username(),
}
if p, ok := u.User.Password(); ok {
cfg2.Password = p
}
if port, err := strconv.Atoi(u.Port()); err == nil {
cfg2.Port = &port
}
seeds = append(seeds, services.ManagedDBSeed{Name: "platform-postgres", DBType: "postgres", Config: cfg2})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
a.Logger.Info("primora backend starting", "address", address)
errCh <- srv.ListenAndServe()
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
if u, err := url.Parse(cfg.DragonflyURL); err == nil && u.Hostname() != "" {
cfg2 := services.DBConnectionConfig{Host: u.Hostname()}
if p, ok := u.User.Password(); ok {
cfg2.Password = p
}
if port, err := strconv.Atoi(u.Port()); err == nil {
cfg2.Port = &port
}
seeds = append(seeds, services.ManagedDBSeed{Name: "platform-dragonfly", DBType: "redis", Config: cfg2})
a.Logger.Info("shutdown signal received, draining in-flight requests")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
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 {
if a.sweepCancel != nil {
a.sweepCancel()
}
if a.Platform != nil {
a.Platform.Close()
}
@@ -18,9 +18,10 @@ INSERT INTO core.api_keys (
name,
prefix,
secret_hash,
scopes,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING id, project_id, name, prefix, secret_hash, created_by_user_id, last_used_at, revoked_at, created_at
) 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, scopes
`
type CreateAPIKeyParams struct {
@@ -28,6 +29,7 @@ type CreateAPIKeyParams struct {
Name string `json:"name"`
Prefix string `json:"prefix"`
SecretHash []byte `json:"secret_hash"`
Scopes []string `json:"scopes"`
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.Prefix,
arg.SecretHash,
arg.Scopes,
arg.CreatedByUserID,
)
var i CoreApiKey
@@ -50,12 +53,13 @@ func (q *Queries) CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (Cor
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
&i.Scopes,
)
return i, err
}
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
AND id = $2
`
@@ -78,13 +82,14 @@ func (q *Queries) GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByID
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
&i.Scopes,
)
return i, err
}
const getAPIKeyByPrefix = `-- name: GetAPIKeyByPrefix :one
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
FROM core.api_keys ak
JOIN core.projects p ON p.id = ak.project_id
@@ -101,6 +106,7 @@ type GetAPIKeyByPrefixRow struct {
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Scopes []string `json:"scopes"`
OrganizationID uuid.UUID `json:"organization_id"`
}
@@ -117,13 +123,14 @@ func (q *Queries) GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIK
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
&i.Scopes,
&i.OrganizationID,
)
return i, err
}
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
ORDER BY created_at DESC
`
@@ -147,6 +154,7 @@ func (q *Queries) ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
&i.Scopes,
); err != nil {
return nil, err
}
@@ -164,7 +172,7 @@ SET revoked_at = NOW()
WHERE project_id = $1
AND id = $2
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 {
@@ -185,6 +193,7 @@ func (q *Queries) RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (Cor
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
&i.Scopes,
)
return i, err
}
@@ -24,7 +24,7 @@ new_org_member AS (
new_project AS (
INSERT INTO core.projects (organization_id, slug, name, description)
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 (
INSERT INTO core.project_members (project_id, user_id, role)
@@ -0,0 +1,441 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: jobs.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createScheduledJob = `-- name: CreateScheduledJob :one
INSERT INTO core.scheduled_jobs (
project_id,
name,
schedule,
url,
secret,
payload,
enabled,
next_run_at,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
`
type CreateScheduledJobParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Url string `json:"url"`
Secret []byte `json:"secret"`
Payload []byte `json:"payload"`
Enabled bool `json:"enabled"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, createScheduledJob,
arg.ProjectID,
arg.Name,
arg.Schedule,
arg.Url,
arg.Secret,
arg.Payload,
arg.Enabled,
arg.NextRunAt,
arg.CreatedByUserID,
)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteScheduledJob = `-- name: DeleteScheduledJob :one
DELETE FROM core.scheduled_jobs
WHERE id = $1
AND project_id = $2
RETURNING id
`
type DeleteScheduledJobParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error) {
row := q.db.QueryRow(ctx, deleteScheduledJob, arg.ID, arg.ProjectID)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const finishScheduledJobRun = `-- name: FinishScheduledJobRun :one
UPDATE core.scheduled_job_runs
SET status = $2,
status_code = $3,
error = $4,
duration_ms = $5,
finished_at = NOW()
WHERE id = $1
RETURNING id, job_id, status, triggered_by, status_code, error, duration_ms, created_at, finished_at
`
type FinishScheduledJobRunParams struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
StatusCode *int32 `json:"status_code"`
Error string `json:"error"`
DurationMs *int32 `json:"duration_ms"`
}
func (q *Queries) FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error) {
row := q.db.QueryRow(ctx, finishScheduledJobRun,
arg.ID,
arg.Status,
arg.StatusCode,
arg.Error,
arg.DurationMs,
)
var i CoreScheduledJobRun
err := row.Scan(
&i.ID,
&i.JobID,
&i.Status,
&i.TriggeredBy,
&i.StatusCode,
&i.Error,
&i.DurationMs,
&i.CreatedAt,
&i.FinishedAt,
)
return i, err
}
const getScheduledJobByID = `-- name: GetScheduledJobByID :one
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
WHERE id = $1
`
func (q *Queries) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, getScheduledJobByID, id)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const insertScheduledJobRun = `-- name: InsertScheduledJobRun :one
INSERT INTO core.scheduled_job_runs (job_id, triggered_by)
VALUES ($1, $2)
RETURNING id, job_id, status, triggered_by, status_code, error, duration_ms, created_at, finished_at
`
type InsertScheduledJobRunParams struct {
JobID uuid.UUID `json:"job_id"`
TriggeredBy string `json:"triggered_by"`
}
func (q *Queries) InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error) {
row := q.db.QueryRow(ctx, insertScheduledJobRun, arg.JobID, arg.TriggeredBy)
var i CoreScheduledJobRun
err := row.Scan(
&i.ID,
&i.JobID,
&i.Status,
&i.TriggeredBy,
&i.StatusCode,
&i.Error,
&i.DurationMs,
&i.CreatedAt,
&i.FinishedAt,
)
return i, err
}
const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
WHERE enabled
AND next_run_at IS NOT NULL
AND next_run_at <= $1
`
func (q *Queries) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error) {
rows, err := q.db.Query(ctx, listDueScheduledJobs, nextRunAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreScheduledJob
for rows.Next() {
var i CoreScheduledJob
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listScheduledJobRuns = `-- name: ListScheduledJobRuns :many
SELECT id, job_id, status, triggered_by, status_code, error, duration_ms, created_at, finished_at FROM core.scheduled_job_runs
WHERE job_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListScheduledJobRunsParams struct {
JobID uuid.UUID `json:"job_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error) {
rows, err := q.db.Query(ctx, listScheduledJobRuns, arg.JobID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreScheduledJobRun
for rows.Next() {
var i CoreScheduledJobRun
if err := rows.Scan(
&i.ID,
&i.JobID,
&i.Status,
&i.TriggeredBy,
&i.StatusCode,
&i.Error,
&i.DurationMs,
&i.CreatedAt,
&i.FinishedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listScheduledJobs = `-- name: ListScheduledJobs :many
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
WHERE project_id = $1
ORDER BY created_at ASC
`
func (q *Queries) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error) {
rows, err := q.db.Query(ctx, listScheduledJobs, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreScheduledJob
for rows.Next() {
var i CoreScheduledJob
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markScheduledJobRan = `-- name: MarkScheduledJobRan :one
UPDATE core.scheduled_jobs
SET last_run_at = $2,
last_status = $3,
next_run_at = $4,
updated_at = NOW()
WHERE id = $1
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
`
type MarkScheduledJobRanParams struct {
ID uuid.UUID `json:"id"`
LastRunAt pgtype.Timestamptz `json:"last_run_at"`
LastStatus string `json:"last_status"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
}
func (q *Queries) MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobRanParams) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, markScheduledJobRan,
arg.ID,
arg.LastRunAt,
arg.LastStatus,
arg.NextRunAt,
)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const pruneScheduledJobRuns = `-- name: PruneScheduledJobRuns :exec
DELETE FROM core.scheduled_job_runs r
WHERE r.job_id = $1
AND r.id NOT IN (
SELECT k.id FROM core.scheduled_job_runs k
WHERE k.job_id = $1
ORDER BY k.created_at DESC
LIMIT $2
)
`
type PruneScheduledJobRunsParams struct {
JobID uuid.UUID `json:"job_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error {
_, err := q.db.Exec(ctx, pruneScheduledJobRuns, arg.JobID, arg.Limit)
return err
}
const updateScheduledJob = `-- name: UpdateScheduledJob :one
UPDATE core.scheduled_jobs
SET name = $3,
schedule = $4,
url = $5,
enabled = $6,
next_run_at = $7,
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
updated_at = NOW()
WHERE id = $1
AND project_id = $2
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
`
type UpdateScheduledJobParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Url string `json:"url"`
Enabled bool `json:"enabled"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
Column8 bool `json:"column_8"`
Secret []byte `json:"secret"`
Column10 bool `json:"column_10"`
Payload []byte `json:"payload"`
}
func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, updateScheduledJob,
arg.ID,
arg.ProjectID,
arg.Name,
arg.Schedule,
arg.Url,
arg.Enabled,
arg.NextRunAt,
arg.Column8,
arg.Secret,
arg.Column10,
arg.Payload,
)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+47 -6
View File
@@ -150,6 +150,7 @@ type CoreApiKey struct {
LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Scopes []string `json:"scopes"`
}
type CoreAuditLog struct {
@@ -273,12 +274,15 @@ type CoreOrganizationMember struct {
}
type CoreProject struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
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 {
@@ -303,6 +307,43 @@ type CoreProjectMember struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreScheduledJob struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Url string `json:"url"`
Secret []byte `json:"secret"`
Payload []byte `json:"payload"`
Enabled bool `json:"enabled"`
LastRunAt pgtype.Timestamptz `json:"last_run_at"`
LastStatus string `json:"last_status"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreScheduledJobRun struct {
ID uuid.UUID `json:"id"`
JobID uuid.UUID `json:"job_id"`
Status string `json:"status"`
TriggeredBy string `json:"triggered_by"`
StatusCode *int32 `json:"status_code"`
Error string `json:"error"`
DurationMs *int32 `json:"duration_ms"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
FinishedAt pgtype.Timestamptz `json:"finished_at"`
}
type 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 {
ID uuid.UUID `json:"id"`
AuthSubject string `json:"auth_subject"`
@@ -59,7 +59,7 @@ INSERT INTO core.projects (
name,
description
) 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 {
@@ -84,6 +84,9 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (C
&i.Name,
&i.Description,
&i.CreatedAt,
&i.RetentionEventsDays,
&i.RetentionAuditDays,
&i.RetentionWebhookDays,
)
return i, err
}
@@ -91,7 +94,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (C
const deleteProjectByID = `-- name: DeleteProjectByID :one
DELETE FROM core.projects
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) {
@@ -104,12 +107,15 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProj
&i.Name,
&i.Description,
&i.CreatedAt,
&i.RetentionEventsDays,
&i.RetentionAuditDays,
&i.RetentionWebhookDays,
)
return i, err
}
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
`
@@ -123,6 +129,9 @@ func (q *Queries) GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject
&i.Name,
&i.Description,
&i.CreatedAt,
&i.RetentionEventsDays,
&i.RetentionAuditDays,
&i.RetentionWebhookDays,
)
return i, err
}
@@ -327,7 +336,7 @@ func (q *Queries) ListProjectMembers(ctx context.Context, projectID uuid.UUID) (
const listProjectsForOrganization = `-- name: ListProjectsForOrganization :many
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
FROM core.projects p
LEFT JOIN core.project_members pm
@@ -350,13 +359,16 @@ type ListProjectsForOrganizationParams struct {
}
type ListProjectsForOrganizationRow struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
MembershipRole NullCoreProjectRole `json:"membership_role"`
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
RetentionEventsDays int32 `json:"retention_events_days"`
RetentionAuditDays int32 `json:"retention_audit_days"`
RetentionWebhookDays int32 `json:"retention_webhook_days"`
MembershipRole NullCoreProjectRole `json:"membership_role"`
}
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.Description,
&i.CreatedAt,
&i.RetentionEventsDays,
&i.RetentionAuditDays,
&i.RetentionWebhookDays,
&i.MembershipRole,
); err != nil {
return nil, err
@@ -416,16 +431,22 @@ const updateProjectByID = `-- name: UpdateProjectByID :one
UPDATE core.projects
SET slug = $2,
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
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 {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
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) {
@@ -434,6 +455,9 @@ func (q *Queries) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDPa
arg.Slug,
arg.Name,
arg.Description,
arg.RetentionEventsDays,
arg.RetentionAuditDays,
arg.RetentionWebhookDays,
)
var i CoreProject
err := row.Scan(
@@ -443,6 +467,9 @@ func (q *Queries) UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDPa
&i.Name,
&i.Description,
&i.CreatedAt,
&i.RetentionEventsDays,
&i.RetentionAuditDays,
&i.RetentionWebhookDays,
)
return i, err
}
@@ -8,6 +8,7 @@ import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Querier interface {
@@ -32,6 +33,7 @@ type Querier interface {
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, 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)
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
@@ -43,9 +45,12 @@ type Querier interface {
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, 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)
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, 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)
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
@@ -63,12 +68,15 @@ type Querier interface {
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, 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)
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
@@ -79,6 +87,7 @@ type Querier interface {
ListComponents(ctx context.Context, projectID uuid.UUID) ([]CoreComponent, error)
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
@@ -88,18 +97,32 @@ type Querier interface {
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, 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)
ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error)
ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, 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
MetricNames(ctx context.Context, projectID uuid.UUID) ([]string, error)
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
SweepExpiredAuditLogs(ctx context.Context) (int64, error)
// Per-project retention sweeps. A retention of 0 days disables the sweep for
// that stream. Pending webhook deliveries are never swept — they may still be
// retrying.
SweepExpiredEvents(ctx context.Context) (int64, error)
// Org-scoped audit rows (project_id NULL) have no project retention — sweep
// them on a fixed 365-day floor so they cannot grow unbounded.
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
TouchAPIKey(ctx context.Context, id uuid.UUID) error
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
@@ -109,8 +132,10 @@ type Querier interface {
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, 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)
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, 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
WHERE project_id = $1
ORDER BY name
LIMIT 500
`
func (q *Queries) ListComponents(ctx context.Context, projectID uuid.UUID) ([]CoreComponent, error) {
+1 -1
View File
@@ -86,7 +86,7 @@ func (d *Client) reset() {
d.session = nil
}
if d.cmd != nil && d.cmd.Process != nil {
d.cmd.Process.Kill()
_ = d.cmd.Process.Kill()
}
d.cmd = nil
d.registered = map[string]bool{}
@@ -204,6 +204,23 @@ func (h *HTTPHandler) dbxRedis(c *gin.Context) {
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.
func (h *HTTPHandler) actorAndProject(c *gin.Context) (*models.Actor, uuid.UUID, bool) {
actor, ok := middleware.RequireActor(c)
+20
View File
@@ -20,6 +20,7 @@ import (
type HTTPHandler struct {
Platform *services.PlatformService
Settings *services.SettingsService
Validate *validator.Validate
Metrics *observability.Metrics
Readiness func(*gin.Context) map[string]any
@@ -29,9 +30,14 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
router.GET("/api/v1/health/liveness", h.liveness)
router.GET("/api/v1/health/readiness", h.readiness)
router.GET("/api/v1/openapi.yaml", h.openapi)
router.GET("/api/v1/metrics", h.metrics)
api := router.Group("/api/v1")
api.GET("/me", h.me)
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.GET("/organizations", h.organizations)
api.POST("/organizations", h.createOrganization)
@@ -91,6 +97,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
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/redis", h.dbxRedis)
api.POST("/projects/:projectID/db-connections/:connectionID/transfer", h.dbxTransfer)
api.GET("/projects/:projectID/integrations", h.listIntegrations)
api.POST("/projects/:projectID/integrations", h.createIntegration)
api.DELETE("/projects/:projectID/integrations/:integrationID", h.deleteIntegration)
@@ -103,6 +110,13 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.GET("/projects/:projectID/webhooks/:webhookID/deliveries", h.listWebhookDeliveries)
api.POST("/projects/:projectID/webhooks/:webhookID/test", h.testWebhook)
api.POST("/projects/:projectID/deploy-markers", h.createDeployMarker)
api.GET("/projects/:projectID/jobs", h.listJobs)
api.POST("/projects/:projectID/jobs", h.createJob)
api.PATCH("/projects/:projectID/jobs/:jobID", h.updateJob)
api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob)
api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns)
api.POST("/projects/:projectID/jobs/:jobID/run", h.runJob)
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
api.GET("/buckets/:bucketID/objects", h.listObjects)
api.POST("/buckets/:bucketID/objects", h.uploadObject)
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
@@ -123,6 +137,12 @@ func (h *HTTPHandler) openapi(c *gin.Context) {
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) {
actor, ok := middleware.RequireActor(c)
if !ok {
+132
View File
@@ -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)
}
+180
View File
@@ -0,0 +1,180 @@
package handlers
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/models"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
// actorProjectJob resolves the actor, :projectID and :jobID.
func (h *HTTPHandler) actorProjectJob(c *gin.Context) (*models.Actor, uuid.UUID, uuid.UUID, bool) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return nil, uuid.Nil, uuid.Nil, false
}
jobID, ok := parseUUIDParam(c, "jobID")
if !ok {
return nil, uuid.Nil, uuid.Nil, false
}
return actor, projectID, jobID, true
}
func (h *HTTPHandler) jobError(c *gin.Context, err error) {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "job not found")
return
}
msg := err.Error()
if strings.Contains(msg, "already exists") {
apperrors.Abort(c, http.StatusConflict, "name_conflict", msg)
return
}
if strings.Contains(msg, "invalid schedule") || strings.Contains(msg, "schedule is required") || strings.Contains(msg, "https") {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
return
}
h.handleError(c, err)
}
func (h *HTTPHandler) listJobs(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListScheduledJobs(c.Request.Context(), actor, projectID)
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) createJob(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.CreateScheduledJobInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateScheduledJob(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) updateJob(c *gin.Context) {
actor, projectID, jobID, ok := h.actorProjectJob(c)
if !ok {
return
}
var body services.UpdateScheduledJobInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.UpdateScheduledJob(c.Request.Context(), actor, projectID, jobID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) deleteJob(c *gin.Context) {
actor, projectID, jobID, ok := h.actorProjectJob(c)
if !ok {
return
}
if err := h.Platform.DeleteScheduledJob(c.Request.Context(), actor, projectID, jobID, middleware.RequestIDFromContext(c)); err != nil {
h.jobError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) listJobRuns(c *gin.Context) {
actor, projectID, jobID, ok := h.actorProjectJob(c)
if !ok {
return
}
limit, ok := parsePaginationQuery(c, "limit", 50, 1, 200)
if !ok {
return
}
result, err := h.Platform.ListScheduledJobRuns(c.Request.Context(), actor, projectID, jobID, limit)
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) runJob(c *gin.Context) {
actor, projectID, jobID, ok := h.actorProjectJob(c)
if !ok {
return
}
result, err := h.Platform.RunScheduledJob(c.Request.Context(), actor, projectID, jobID, middleware.RequestIDFromContext(c))
if err != nil {
h.jobError(c, err)
return
}
c.JSON(http.StatusAccepted, result)
}
// realtimeStream pushes project domain events (document/object/issue/job) to
// the dashboard over SSE — the same pattern as telemetryStream.
func (h *HTTPHandler) realtimeStream(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
ch, err := h.Platform.SubscribeRealtime(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
defer h.Platform.UnsubscribeRealtime(projectID, ch)
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
if _, err := c.Writer.WriteString(": connected\n\n"); err == nil {
c.Writer.Flush()
}
keepalive := time.NewTicker(25 * time.Second)
defer keepalive.Stop()
c.Stream(func(w io.Writer) bool {
select {
case msg, ok := <-ch:
if !ok {
return false
}
c.SSEvent("event", json.RawMessage(msg))
return true
case <-keepalive.C:
c.SSEvent("ping", "keepalive")
return true
case <-c.Request.Context().Done():
return false
}
})
}
+29 -18
View File
@@ -17,12 +17,15 @@ type organizationMembershipResponse struct {
}
type projectResponse struct {
ID string `json:"id"`
OrganizationID string `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
MembershipRole *string `json:"membership_role"`
ID string `json:"id"`
OrganizationID string `json:"organization_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description *string `json:"description"`
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 {
@@ -30,6 +33,7 @@ type apiKeyResponse struct {
ProjectID string `json:"project_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
Scopes []string `json:"scopes"`
LastUsedAt *time.Time `json:"last_used_at"`
RevokedAt *time.Time `json:"revoked_at"`
}
@@ -114,23 +118,29 @@ func toProjectFromRow(row db.ListProjectsForOrganizationRow) projectResponse {
membershipRole = &role
}
return projectResponse{
ID: row.ID.String(),
OrganizationID: row.OrganizationID.String(),
Slug: row.Slug,
Name: row.Name,
Description: row.Description,
MembershipRole: membershipRole,
ID: row.ID.String(),
OrganizationID: row.OrganizationID.String(),
Slug: row.Slug,
Name: row.Name,
Description: row.Description,
MembershipRole: membershipRole,
RetentionEventsDays: row.RetentionEventsDays,
RetentionAuditDays: row.RetentionAuditDays,
RetentionWebhookDays: row.RetentionWebhookDays,
}
}
func toProjectFromCore(row db.CoreProject) projectResponse {
return projectResponse{
ID: row.ID.String(),
OrganizationID: row.OrganizationID.String(),
Slug: row.Slug,
Name: row.Name,
Description: row.Description,
MembershipRole: nil,
ID: row.ID.String(),
OrganizationID: row.OrganizationID.String(),
Slug: row.Slug,
Name: row.Name,
Description: row.Description,
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(),
Name: row.Name,
Prefix: row.Prefix,
Scopes: row.Scopes,
LastUsedAt: timestamptzPtr(row.LastUsedAt),
RevokedAt: timestamptzPtr(row.RevokedAt),
}
@@ -35,8 +35,11 @@ func Compression() gin.HandlerFunc {
return
}
// Skip compression for small responses or streaming
if c.Request.Method == "HEAD" || c.Request.URL.Path == "/api/v1/health/liveness" {
// Skip compression for small responses or streaming — gzip buffers
// 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()
return
}
+50 -5
View File
@@ -37,6 +37,9 @@ type AuthMiddleware struct {
Redis *redis.Client
Verifier *auth.Verifier
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 {
@@ -44,6 +47,16 @@ type RateLimitConfig struct {
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 {
return func(c *gin.Context) {
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"))
}
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.
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"))
if apiKey == "" {
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 "))
}
if strings.HasPrefix(strings.ToLower(authz), "bearer ") {
apiKey = ""
actor, err := m.resolveJWTActor(c.Request.Context(), token)
if err != nil {
apperrors.Abort(c, http.StatusUnauthorized, "invalid_token", err.Error())
@@ -104,7 +118,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
if actor.UserID != nil {
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
}
c.Set(actorKey, actor)
@@ -122,7 +136,11 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error())
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
}
c.Set(actorKey, actor)
@@ -264,9 +282,36 @@ func (m AuthMiddleware) resolveAPIKeyActor(ctx context.Context, rawKey string) (
OrganizationID: &orgID,
APIKeyID: &apiKeyID,
APIKeyPrefix: prefix,
Scopes: row.Scopes,
}, 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 {
return pgtype.UUID{Bytes: id, Valid: true}
}
@@ -0,0 +1,66 @@
package middleware
import (
"net/http"
"testing"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
func TestRequiredAPIKeyScope(t *testing.T) {
cases := []struct {
method, path, want string
}{
{http.MethodPost, "/api/v1/ingest", "ingest"},
{http.MethodOptions, "/api/v1/ingest", "read"}, // preflight passes through CORS
{http.MethodGet, "/api/v1/projects/x/events", "read"},
{http.MethodGet, "/api/v1/projects/x/api-keys", "read"},
{http.MethodPost, "/api/v1/projects/x/api-keys", "admin"},
{http.MethodDelete, "/api/v1/projects/x/api-keys/y", "admin"},
{http.MethodPatch, "/api/v1/projects/x/members/y", "admin"},
{http.MethodPatch, "/api/v1/projects/x", "admin"},
{http.MethodDelete, "/api/v1/projects/x", "admin"},
{http.MethodPatch, "/api/v1/organizations/x", "admin"},
{http.MethodDelete, "/api/v1/organizations/x", "admin"},
{http.MethodPost, "/api/v1/organizations/x/invitations", "admin"},
{http.MethodPost, "/api/v1/bootstrap", "admin"},
{http.MethodPost, "/api/v1/projects/x/buckets", "write"},
{http.MethodPost, "/api/v1/projects/x/db-connections/y/transfer", "write"},
{http.MethodDelete, "/api/v1/buckets/x", "write"},
{http.MethodPost, "/api/v1/projects/x/deploy-markers", "write"},
}
for _, tc := range cases {
if got := requiredAPIKeyScope(tc.method, tc.path); got != tc.want {
t.Errorf("%s %s: got %q, want %q", tc.method, tc.path, got, tc.want)
}
}
}
func TestActorHasScope(t *testing.T) {
key := func(scopes ...string) *models.Actor {
return &models.Actor{Type: models.ActorTypeAPIKey, Scopes: scopes}
}
user := &models.Actor{Type: models.ActorTypeUser}
if !user.HasScope("admin") {
t.Error("user actors bypass scope checks")
}
if !key("admin").HasScope("read") {
t.Error("admin should satisfy every requirement")
}
if !key("write").HasScope("read") || !key("write").HasScope("ingest") {
t.Error("write should imply read and ingest")
}
if key("write").HasScope("admin") {
t.Error("write must not imply admin")
}
if key("read").HasScope("write") || key("read").HasScope("ingest") {
t.Error("read must not imply write or ingest")
}
if !key("ingest").HasScope("ingest") || key("ingest").HasScope("read") {
t.Error("ingest keys only reach /ingest")
}
if key().HasScope("read") {
t.Error("scopeless key has no access")
}
}
+33
View File
@@ -21,6 +21,7 @@ type Actor struct {
OrganizationID *uuid.UUID
APIKeyID *uuid.UUID
APIKeyPrefix string
Scopes []string
}
func (a *Actor) IsUser() bool {
@@ -30,3 +31,35 @@ func (a *Actor) IsUser() bool {
func (a *Actor) IsAPIKey() bool {
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
}
+24 -2
View File
@@ -1,6 +1,7 @@
package observability
import (
"fmt"
"sync"
"time"
)
@@ -43,12 +44,12 @@ func (m *Metrics) DecrementActive() {
func (m *Metrics) GetStats() map[string]any {
m.mu.RLock()
defer m.mu.RUnlock()
avgResponseTime := int64(0)
if m.requestCount > 0 {
avgResponseTime = m.totalResponseTime.Milliseconds() / m.requestCount
}
return map[string]any{
"total_requests": m.requestCount,
"total_errors": m.errorCount,
@@ -56,3 +57,24 @@ func (m *Metrics) GetStats() map[string]any {
"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())
}
@@ -56,3 +56,35 @@ func (r *CoreRepository) GetAPIKeyByPrefix(ctx context.Context, prefix string) (
func (r *CoreRepository) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
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)
}
+274 -46
View File
@@ -2,6 +2,7 @@ package services
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -9,7 +10,6 @@ import (
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
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
// 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 {
Host string `json:"host"`
Port *int `json:"port,omitempty"`
@@ -30,14 +31,6 @@ type DBConnectionConfig struct {
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 {
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"`
@@ -88,15 +81,44 @@ func toDBConnectionSummary(row db.CoreDbConnection) DBConnectionSummary {
}
}
// dbxConnectionName derives the DBX-internal name. The project prefix keeps
// same-named connections in different projects from clobbering each other in
// DBX's shared storage.
// dbxConnectionName derives the DBX-internal name. The full project UUID
// prefix keeps same-named connections in different projects from colliding
// in DBX's shared storage.
func dbxConnectionName(projectID uuid.UUID, name string) string {
short := strings.ReplaceAll(projectID.String(), "-", "")[:8]
return "prj_" + short + "_" + name
return "prj_" + strings.ReplaceAll(projectID.String(), "-", "") + "_" + 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)
args := map[string]any{
"name": dbxConnectionName(row.ProjectID, row.Name),
@@ -113,7 +135,11 @@ func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) map[string]any {
args["username"] = cfg.Username
}
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 {
args["ssl"] = *cfg.SSL
@@ -121,32 +147,7 @@ func (s *PlatformService) dbxArgsFor(row db.CoreDbConnection) map[string]any {
if cfg.DriverProfile != "" {
args["driver_profile"] = cfg.DriverProfile
}
return args
}
// 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,
})
}
return args, nil
}
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 {
return nil, err
}
s.seedManagedConnections(ctx, projectID)
rows, err := s.repo.Queries().ListDBConnections(ctx, projectID)
if err != nil {
return nil, err
@@ -183,12 +183,16 @@ func (s *PlatformService) CreateDBConnection(ctx context.Context, actor *models.
if name == "" {
return DBConnectionSummary{}, errors.New("name must contain letters or digits")
}
sealedPassword, err := s.sealPassword(input.Password)
if err != nil {
return DBConnectionSummary{}, err
}
cfg := DBConnectionConfig{
Host: strings.TrimSpace(input.Host),
Port: input.Port,
Database: strings.TrimSpace(input.Database),
Username: strings.TrimSpace(input.Username),
Password: input.Password,
Password: sealedPassword,
SSL: input.SSL,
DriverProfile: strings.TrimSpace(input.DriverProfile),
}
@@ -269,7 +273,11 @@ func (s *PlatformService) connForTool(ctx context.Context, actor *models.Actor,
if s.dbx == nil {
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 row, nil
@@ -500,3 +508,223 @@ func (s *PlatformService) ExecuteDBXRedis(ctx context.Context, actor *models.Act
}
return map[string]any{"output": text}, nil
}
// TransferInput describes a DB-to-DB link: rows read from the source
// connection are written to the target connection.
type TransferInput struct {
TargetConnectionID string `json:"target_connection_id" validate:"required,uuid"`
Query string `json:"query" validate:"required"`
Database string `json:"database"`
// SQL targets: destination table (schema-qualified allowed).
TargetTable string `json:"target_table"`
// Redis targets: SET key template with {column} placeholders; the value is
// value_column's cell, or the whole row as JSON when omitted.
KeyPattern string `json:"key_pattern"`
ValueColumn string `json:"value_column"`
// Optional TTL in seconds applied to each Redis SET.
TTLSeconds *int `json:"ttl_seconds" validate:"omitempty,min=0"`
Limit int `json:"limit" validate:"omitempty,min=1,max=1000"`
}
const transferMaxRows = 1000
// TransferDBRows moves data between two saved connections of the same
// project. Both connections must accept write-level access.
func (s *PlatformService) TransferDBRows(ctx context.Context, actor *models.Actor, projectID, sourceID uuid.UUID, input TransferInput, requestID string) (map[string]any, error) {
source, err := s.connForTool(ctx, actor, projectID, sourceID, true)
if err != nil {
return nil, err
}
targetID, err := uuid.Parse(input.TargetConnectionID)
if err != nil {
return nil, errors.New("target_connection_id must be a uuid")
}
target, err := s.connForTool(ctx, actor, projectID, targetID, true)
if err != nil {
return nil, err
}
limit := input.Limit
if limit <= 0 || limit > transferMaxRows {
limit = transferMaxRows
}
args := map[string]any{"connection_name": dbxConnectionName(projectID, source.Name), "sql": input.Query}
if input.Database != "" {
args["database"] = input.Database
}
text, err := s.dbx.Call(ctx, "dbx_execute_query", args)
if err != nil {
return nil, err
}
table, _ := dbx.ParseMDTable(text)
if table == nil || len(table.Columns) == 0 {
return nil, errors.New("source query did not return a result table")
}
rows := table.Rows
truncated := false
if len(rows) > limit {
rows = rows[:limit]
truncated = true
}
var transferred int
if target.DbType == "redis" {
transferred, err = s.transferToRedis(ctx, projectID, target, table.Columns, rows, input)
} else {
transferred, err = s.transferToSQL(ctx, projectID, target, table.Columns, rows, input)
}
if err != nil {
return nil, err
}
project, perr := s.repo.Queries().GetProjectByID(ctx, projectID)
if perr == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "db_connection.transferred", "db_connection", source.ID.String(), map[string]any{
"source": source.Name,
"target": target.Name,
"transferred": transferred,
"truncated": truncated,
}))
}
return map[string]any{
"transferred": transferred,
"truncated": truncated,
"source": source.Name,
"target": target.Name,
}, nil
}
// transferToSQL INSERTs rows into target_table in batches of 100. Cell values
// arrive as text from DBX's markdown output; literal "NULL" maps to SQL NULL.
func (s *PlatformService) transferToSQL(ctx context.Context, projectID uuid.UUID, target db.CoreDbConnection, cols []string, rows [][]string, input TransferInput) (int, error) {
tableIdent, ok := safeDottedIdent(input.TargetTable)
if !ok {
return 0, errors.New("target_table must be a plain identifier or schema.table")
}
colIdents := make([]string, len(cols))
for i, col := range cols {
ident, ok := safeIdent(col)
if !ok {
return 0, fmt.Errorf("source column %q is not a usable identifier", col)
}
colIdents[i] = `"` + ident + `"`
}
transferred := 0
const batch = 100
for start := 0; start < len(rows); start += batch {
end := min(start+batch, len(rows))
var values strings.Builder
values.WriteString("INSERT INTO " + tableIdent + " (" + strings.Join(colIdents, ", ") + ") VALUES ")
for i, row := range rows[start:end] {
if i > 0 {
values.WriteString(", ")
}
values.WriteString("(")
for j := range cols {
if j > 0 {
values.WriteString(", ")
}
cell := ""
if j < len(row) {
cell = row[j]
}
if cell == "NULL" {
values.WriteString("NULL")
} else {
values.WriteString(sqlQuote(cell))
}
}
values.WriteString(")")
}
if _, err := s.dbx.Call(ctx, "dbx_execute_query", map[string]any{
"connection_name": dbxConnectionName(projectID, target.Name),
"sql": values.String(),
}); err != nil {
return transferred, fmt.Errorf("insert batch at row %d: %w", start, err)
}
transferred += end - start
}
return transferred, nil
}
// transferToRedis SETs one key per row. Placeholders like {email} in
// key_pattern are replaced with that row's cell value.
var keyPlaceholder = regexp.MustCompile(`\{([A-Za-z0-9_]+)\}`)
func (s *PlatformService) transferToRedis(ctx context.Context, projectID uuid.UUID, target db.CoreDbConnection, cols []string, rows [][]string, input TransferInput) (int, error) {
if input.KeyPattern == "" {
return 0, errors.New("key_pattern is required for redis targets")
}
colIndex := map[string]int{}
for i, col := range cols {
colIndex[col] = i
}
for _, ph := range keyPlaceholder.FindAllStringSubmatch(input.KeyPattern, -1) {
if _, ok := colIndex[ph[1]]; !ok {
return 0, fmt.Errorf("key_pattern references unknown column %q", ph[1])
}
}
if input.ValueColumn != "" {
if _, ok := colIndex[input.ValueColumn]; !ok {
return 0, fmt.Errorf("value_column %q not in source columns", input.ValueColumn)
}
}
transferred := 0
for _, row := range rows {
key := keyPlaceholder.ReplaceAllStringFunc(input.KeyPattern, func(m string) string {
col := keyPlaceholder.FindStringSubmatch(m)[1]
if i, ok := colIndex[col]; ok && i < len(row) {
return row[i]
}
return ""
})
var value string
if input.ValueColumn != "" {
value = row[colIndex[input.ValueColumn]]
} else {
obj := map[string]string{}
for i, col := range cols {
if i < len(row) {
obj[col] = row[i]
}
}
raw, _ := json.Marshal(obj)
value = string(raw)
}
cmd := "SET " + redisQuote(key) + " " + redisQuote(value)
if input.TTLSeconds != nil && *input.TTLSeconds > 0 {
cmd += fmt.Sprintf(" EX %d", *input.TTLSeconds)
}
if _, err := s.dbx.Call(ctx, "dbx_execute_redis_command", map[string]any{
"connection_name": dbxConnectionName(projectID, target.Name),
"command": cmd,
}); err != nil {
return transferred, fmt.Errorf("SET %q: %w", key, err)
}
transferred++
}
return transferred, nil
}
var dottedIdent = regexp.MustCompile(`^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)?$`)
func safeDottedIdent(s string) (string, bool) {
if !dottedIdent.MatchString(s) {
return "", false
}
parts := strings.Split(s, ".")
for i := range parts {
parts[i] = `"` + parts[i] + `"`
}
return strings.Join(parts, "."), true
}
func sqlQuote(v string) string {
return "'" + strings.ReplaceAll(v, "'", "''") + "'"
}
func redisQuote(v string) string {
return `"` + strings.ReplaceAll(strings.ReplaceAll(v, `\`, `\\`), `"`, `\"`) + `"`
}
+591
View File
@@ -0,0 +1,591 @@
package services
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/robfig/cron/v3"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
)
// WebhookEventJobRun fires on every scheduled-job execution, successful or not.
const WebhookEventJobRun = "job.run"
// maxJobRunsPerJob bounds run history — older rows are pruned on each finish.
const maxJobRunsPerJob = 200
type ScheduledJobSummary struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
URL string `json:"url"`
Payload json.RawMessage `json:"payload"`
Enabled bool `json:"enabled"`
HasSecret bool `json:"has_secret"`
LastRunAt *time.Time `json:"last_run_at"`
LastStatus *string `json:"last_status"`
NextRunAt *time.Time `json:"next_run_at"`
CreatedAt time.Time `json:"created_at"`
// Secret is populated only when Primora generated it — one chance to see it.
Secret string `json:"secret,omitempty"`
}
type ScheduledJobRunSummary struct {
ID uuid.UUID `json:"id"`
JobID uuid.UUID `json:"job_id"`
Status string `json:"status"`
TriggeredBy string `json:"triggered_by"`
StatusCode *int32 `json:"status_code"`
Error string `json:"error"`
DurationMs *int32 `json:"duration_ms"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
type CreateScheduledJobInput struct {
Name string `json:"name" validate:"required,min=2"`
Schedule string `json:"schedule" validate:"required"`
URL string `json:"url" validate:"required,url"`
Secret string `json:"secret"`
Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
}
type UpdateScheduledJobInput struct {
Name *string `json:"name" validate:"omitempty,min=2"`
Schedule *string `json:"schedule"`
URL *string `json:"url" validate:"omitempty,url"`
Secret *string `json:"secret"`
Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
}
func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
out := ScheduledJobSummary{
ID: row.ID,
ProjectID: row.ProjectID,
Name: row.Name,
Schedule: row.Schedule,
URL: row.Url,
Payload: row.Payload,
Enabled: row.Enabled,
HasSecret: len(row.Secret) > 0,
CreatedAt: row.CreatedAt.Time,
}
if row.LastRunAt.Valid {
t := row.LastRunAt.Time
out.LastRunAt = &t
}
if row.LastStatus != "" {
s := row.LastStatus
out.LastStatus = &s
}
if row.NextRunAt.Valid {
t := row.NextRunAt.Time
out.NextRunAt = &t
}
return out
}
func toJobRunSummary(row db.CoreScheduledJobRun) ScheduledJobRunSummary {
out := ScheduledJobRunSummary{
ID: row.ID,
JobID: row.JobID,
Status: row.Status,
TriggeredBy: row.TriggeredBy,
StatusCode: row.StatusCode,
Error: row.Error,
DurationMs: row.DurationMs,
StartedAt: row.CreatedAt.Time,
}
if row.FinishedAt.Valid {
t := row.FinishedAt.Time
out.FinishedAt = &t
}
return out
}
// parseSchedule accepts standard 5-field cron expressions plus robfig
// descriptors (@hourly, @every 30m, …).
func parseSchedule(expr string) (cron.Schedule, error) {
expr = strings.TrimSpace(expr)
if expr == "" {
return nil, errors.New("schedule is required")
}
sched, err := cron.ParseStandard(expr)
if err != nil {
return nil, fmt.Errorf("invalid schedule %q — expected a cron expression like \"*/15 * * * *\" or \"@every 1h\"", expr)
}
return sched, nil
}
func nextRunAt(sched cron.Schedule, from time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: sched.Next(from), Valid: true}
}
func (s *PlatformService) jobFor(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, write bool) (db.CoreScheduledJob, error) {
roles := []string{"admin", "developer", "viewer"}
if write {
roles = []string{"admin", "developer"}
}
if err := s.requireProjectRole(ctx, actor, projectID, roles...); err != nil {
return db.CoreScheduledJob{}, err
}
row, err := s.repo.Queries().GetScheduledJobByID(ctx, jobID)
if err != nil {
return db.CoreScheduledJob{}, err
}
if row.ProjectID != projectID {
return db.CoreScheduledJob{}, fmt.Errorf("job access denied")
}
return row, nil
}
func (s *PlatformService) ListScheduledJobs(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ScheduledJobSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListScheduledJobs(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]ScheduledJobSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toJobSummary(row))
}
return out, nil
}
func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateScheduledJobInput, requestID string) (ScheduledJobSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return ScheduledJobSummary{}, err
}
jobURL, err := normalizeWebhookURL(input.URL)
if err != nil {
return ScheduledJobSummary{}, err
}
sched, err := parseSchedule(input.Schedule)
if err != nil {
return ScheduledJobSummary{}, err
}
secret := strings.TrimSpace(input.Secret)
generated := false
if secret == "" {
if secret, err = randomToken(16); err != nil {
return ScheduledJobSummary{}, err
}
generated = true
}
secretBytes, err := s.enc.Encrypt([]byte(secret))
if err != nil {
return ScheduledJobSummary{}, err
}
payload := []byte("{}")
if input.Payload != nil {
payload, _ = json.Marshal(input.Payload)
}
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
var createdBy pgtype.UUID
if actor.UserID != nil {
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
}
var next pgtype.Timestamptz
if enabled {
next = nextRunAt(sched, time.Now())
}
row, err := s.repo.Queries().CreateScheduledJob(ctx, db.CreateScheduledJobParams{
ProjectID: projectID,
Name: strings.TrimSpace(input.Name),
Schedule: strings.TrimSpace(input.Schedule),
Url: jobURL,
Secret: secretBytes,
Payload: payload,
Enabled: enabled,
NextRunAt: next,
CreatedByUserID: createdBy,
})
if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
return ScheduledJobSummary{}, fmt.Errorf("a job named %q already exists", input.Name)
}
return ScheduledJobSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.created", "scheduled_job", row.ID.String(), map[string]any{
"name": row.Name, "schedule": row.Schedule, "url": row.Url,
}))
}
summary := toJobSummary(row)
if generated {
summary.Secret = secret
}
return summary, nil
}
func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, input UpdateScheduledJobInput, requestID string) (ScheduledJobSummary, error) {
row, err := s.jobFor(ctx, actor, projectID, jobID, true)
if err != nil {
return ScheduledJobSummary{}, err
}
name := row.Name
if input.Name != nil {
name = strings.TrimSpace(*input.Name)
}
schedule := row.Schedule
if input.Schedule != nil {
schedule = strings.TrimSpace(*input.Schedule)
}
jobURL := row.Url
if input.URL != nil {
if jobURL, err = normalizeWebhookURL(*input.URL); err != nil {
return ScheduledJobSummary{}, err
}
}
enabled := row.Enabled
if input.Enabled != nil {
enabled = *input.Enabled
}
sched, err := parseSchedule(schedule)
if err != nil {
return ScheduledJobSummary{}, err
}
var next pgtype.Timestamptz
if enabled {
next = nextRunAt(sched, time.Now())
}
rotateSecret := input.Secret != nil
var secretBytes []byte
if rotateSecret {
secret := strings.TrimSpace(*input.Secret)
if secret == "" {
if secret, err = randomToken(16); err != nil {
return ScheduledJobSummary{}, err
}
}
if secretBytes, err = s.enc.Encrypt([]byte(secret)); err != nil {
return ScheduledJobSummary{}, err
}
}
rotatePayload := input.Payload != nil
var payload []byte
if rotatePayload {
payload, _ = json.Marshal(input.Payload)
}
updated, err := s.repo.Queries().UpdateScheduledJob(ctx, db.UpdateScheduledJobParams{
ID: jobID,
ProjectID: projectID,
Name: name,
Schedule: schedule,
Url: jobURL,
Enabled: enabled,
NextRunAt: next,
Column8: rotateSecret,
Secret: secretBytes,
Column10: rotatePayload,
Payload: payload,
})
if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
return ScheduledJobSummary{}, fmt.Errorf("a job named %q already exists", name)
}
return ScheduledJobSummary{}, err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.updated", "scheduled_job", jobID.String(), map[string]any{
"name": updated.Name, "schedule": updated.Schedule, "url": updated.Url, "enabled": updated.Enabled,
}))
summary := toJobSummary(updated)
if rotateSecret && strings.TrimSpace(*input.Secret) == "" {
if plain, decErr := s.enc.Decrypt(updated.Secret); decErr == nil {
summary.Secret = string(plain)
}
}
return summary, nil
}
func (s *PlatformService) DeleteScheduledJob(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, requestID string) error {
row, err := s.jobFor(ctx, actor, projectID, jobID, true)
if err != nil {
return err
}
if _, err := s.repo.Queries().DeleteScheduledJob(ctx, db.DeleteScheduledJobParams{
ID: jobID,
ProjectID: projectID,
}); err != nil {
return err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.deleted", "scheduled_job", jobID.String(), map[string]any{
"name": row.Name,
}))
return nil
}
func (s *PlatformService) ListScheduledJobRuns(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, limit int32) ([]ScheduledJobRunSummary, error) {
if _, err := s.jobFor(ctx, actor, projectID, jobID, false); err != nil {
return nil, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.repo.Queries().ListScheduledJobRuns(ctx, db.ListScheduledJobRunsParams{
JobID: jobID,
Limit: limit,
})
if err != nil {
return nil, err
}
out := make([]ScheduledJobRunSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toJobRunSummary(row))
}
return out, nil
}
// RunScheduledJob queues an out-of-band manual run. The run row is returned
// in "running" state; the result lands asynchronously.
func (s *PlatformService) RunScheduledJob(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, requestID string) (ScheduledJobRunSummary, error) {
job, err := s.jobFor(ctx, actor, projectID, jobID, true)
if err != nil {
return ScheduledJobRunSummary{}, err
}
if s.scheduler == nil {
return ScheduledJobRunSummary{}, errors.New("scheduler unavailable")
}
run, err := s.scheduler.enqueue(ctx, job, "manual")
if err != nil {
return ScheduledJobRunSummary{}, err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.run", "scheduled_job", jobID.String(), map[string]any{
"name": job.Name, "run_id": run.ID.String(),
}))
return toJobRunSummary(run), nil
}
// ---------------------------------------------------------------------------
// JobScheduler — in-process ticker, signed POST per due job, run history.
// ---------------------------------------------------------------------------
// jobQueries is the slice of db.Queries the scheduler needs — narrow so tests
// can stub it.
type jobQueries interface {
ListDueScheduledJobs(ctx context.Context, now pgtype.Timestamptz) ([]db.CoreScheduledJob, error)
InsertScheduledJobRun(ctx context.Context, params db.InsertScheduledJobRunParams) (db.CoreScheduledJobRun, error)
FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error)
MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error)
PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error
}
type JobScheduler struct {
repo jobQueries
enc *secrets.Encryptor
logger *slog.Logger
http *http.Client
interval time.Duration
queue chan jobExec
quit chan struct{}
wg sync.WaitGroup
started sync.Once
// onEvent fans a finished run out to webhooks and the realtime stream.
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
}
type jobExec struct {
job db.CoreScheduledJob
run db.CoreScheduledJobRun
}
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)) *JobScheduler {
if logger == nil {
logger = slog.Default()
}
return &JobScheduler{
repo: q,
enc: enc,
logger: logger,
http: &http.Client{Timeout: 10 * time.Second},
interval: 15 * time.Second,
queue: make(chan jobExec, 256),
quit: make(chan struct{}),
onEvent: onEvent,
}
}
func (j *JobScheduler) Start(ctx context.Context) {
j.started.Do(func() {
j.wg.Add(1)
go j.run(ctx)
})
}
func (j *JobScheduler) Stop() {
close(j.quit)
j.wg.Wait()
}
func (j *JobScheduler) run(ctx context.Context) {
defer j.wg.Done()
ticker := time.NewTicker(j.interval)
defer ticker.Stop()
for {
select {
case exec := <-j.queue:
j.execute(exec.job, exec.run)
case <-ticker.C:
j.scan()
case <-j.quit:
return
case <-ctx.Done():
return
}
}
}
// scan picks up enabled jobs whose next_run_at has passed. Missed occurrences
// collapse into a single run — Primora fires once, not once per missed tick.
func (j *JobScheduler) scan() {
ctx := context.Background()
jobs, err := j.repo.ListDueScheduledJobs(ctx, pgtype.Timestamptz{Time: time.Now(), Valid: true})
if err != nil {
j.logger.Warn("scheduled job scan failed", "error", err)
return
}
for _, job := range jobs {
run, err := j.repo.InsertScheduledJobRun(ctx, db.InsertScheduledJobRunParams{JobID: job.ID, TriggeredBy: "schedule"})
if err != nil {
j.logger.Warn("job run insert failed", "job_id", job.ID, "error", err)
j.markRan(ctx, job, "failed")
continue
}
j.execute(job, run)
}
}
// enqueue inserts the run row synchronously so callers get a run ID, then
// hands the execution to the worker.
func (j *JobScheduler) enqueue(ctx context.Context, job db.CoreScheduledJob, triggeredBy string) (db.CoreScheduledJobRun, error) {
run, err := j.repo.InsertScheduledJobRun(ctx, db.InsertScheduledJobRunParams{
JobID: job.ID,
TriggeredBy: triggeredBy,
})
if err != nil {
return db.CoreScheduledJobRun{}, err
}
select {
case j.queue <- jobExec{job: job, run: run}:
case <-j.quit:
default:
j.logger.Warn("job queue full, run dropped", "job_id", job.ID, "run_id", run.ID)
}
return run, nil
}
func (j *JobScheduler) execute(job db.CoreScheduledJob, run db.CoreScheduledJobRun) {
ctx := context.Background()
start := time.Now()
statusCode, sendErr := j.deliver(ctx, job, run)
status := "success"
errText := ""
if sendErr != nil {
status = "failed"
errText = sendErr.Error()
}
duration := int32(time.Since(start).Milliseconds())
_, _ = j.repo.FinishScheduledJobRun(ctx, db.FinishScheduledJobRunParams{
ID: run.ID,
Status: status,
StatusCode: statusCode,
Error: errText,
DurationMs: &duration,
})
j.markRan(ctx, job, status)
_ = j.repo.PruneScheduledJobRuns(ctx, db.PruneScheduledJobRunsParams{JobID: job.ID, Limit: maxJobRunsPerJob})
if j.onEvent != nil {
j.onEvent(ctx, job.ProjectID, WebhookEventJobRun, map[string]any{
"job_id": job.ID.String(),
"job_name": job.Name,
"run_id": run.ID.String(),
"triggered_by": run.TriggeredBy,
"status": status,
"status_code": statusCode,
"error": errText,
"duration_ms": duration,
})
}
}
func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, status string) {
var next pgtype.Timestamptz
if job.Enabled {
if sched, err := parseSchedule(job.Schedule); err == nil {
next = nextRunAt(sched, time.Now())
}
}
if _, err := j.repo.MarkScheduledJobRan(ctx, db.MarkScheduledJobRanParams{
ID: job.ID,
LastRunAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastStatus: status,
NextRunAt: next,
}); err != nil {
j.logger.Warn("job mark-ran failed", "job_id", job.ID, "error", err)
}
}
// deliver POSTs the job payload to its URL, signed like a webhook delivery.
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
payload, _ := json.Marshal(map[string]any{
"event": WebhookEventJobRun,
"project_id": job.ProjectID.String(),
"job_id": job.ID.String(),
"job_name": job.Name,
"run_id": run.ID.String(),
"triggered_by": run.TriggeredBy,
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": json.RawMessage(job.Payload),
})
secret := ""
if len(job.Secret) > 0 && j.enc != nil {
if plain, err := j.enc.Decrypt(job.Secret); err == nil {
secret = string(plain)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, job.Url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Primora-Event", WebhookEventJobRun)
req.Header.Set("X-Primora-Job", job.ID.String())
req.Header.Set("X-Primora-Run", run.ID.String())
req.Header.Set("X-Primora-Signature", "sha256="+signBody(secret, payload))
resp, err := j.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
code := int32(resp.StatusCode)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &code, fmt.Errorf("endpoint returned %d", resp.StatusCode)
}
return &code, nil
}
@@ -33,10 +33,11 @@ type PlatformService struct {
mailer *Mailer
publicURL string
dbx *dbx.Client
managedDBSeeds []ManagedDBSeed
hub *EventHub
realtime *EventHub
enc *secrets.Encryptor
dispatcher *WebhookDispatcher
scheduler *JobScheduler
}
type BootstrapInput struct {
@@ -67,6 +68,11 @@ type UpdateProjectInput struct {
Name string `json:"name" validate:"required,min=2"`
Slug string `json:"slug" validate:"required,min=2"`
Description *string `json:"description"`
// Retention windows in days; nil leaves the current value, 0 disables
// the sweeper for that stream.
RetentionEventsDays *int32 `json:"retention_events_days" validate:"omitempty,min=0,max=3650"`
RetentionAuditDays *int32 `json:"retention_audit_days" validate:"omitempty,min=0,max=3650"`
RetentionWebhookDays *int32 `json:"retention_webhook_days" validate:"omitempty,min=0,max=3650"`
}
type CreateInvitationInput struct {
@@ -78,7 +84,8 @@ type CreateInvitationInput struct {
}
type CreateAPIKeyInput struct {
Name string `json:"name" validate:"required,min=2"`
Name string `json:"name" validate:"required,min=2"`
Scopes []string `json:"scopes" validate:"omitempty,dive,oneof=ingest read write admin"`
}
type CreateBucketInput struct {
@@ -216,20 +223,51 @@ type InvitationSummary struct {
Status string `json:"status"`
}
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, managedDBSeeds []ManagedDBSeed, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, managedDBSeeds: managedDBSeeds, hub: NewEventHub(), enc: enc}
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc}
if enc != nil {
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
s.dispatcher.Start(context.Background())
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent)
s.scheduler.Start(context.Background())
}
return s
}
// Close stops the webhook dispatcher. Idempotent.
// Close stops the webhook dispatcher and job scheduler. Idempotent.
func (s *PlatformService) Close() {
if s.dispatcher != nil {
s.dispatcher.Stop()
}
if s.scheduler != nil {
s.scheduler.Stop()
}
}
// publishEvent fans a domain event out to realtime SSE subscribers and to
// matching webhooks. Failures are logged by the callees — events never block
// the request that produced them.
func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
if s.realtime != nil {
s.realtime.Broadcast(projectID.String(), map[string]any{
"type": eventType,
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": data,
})
}
s.dispatchEvent(ctx, projectID, eventType, data)
}
// SubscribeRealtime registers an SSE consumer for project domain events.
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
return s.realtime.Subscribe(projectID.String()), nil
}
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
s.realtime.Unsubscribe(projectID.String(), ch)
}
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
@@ -736,10 +774,13 @@ func (s *PlatformService) UpdateProject(ctx context.Context, actor *models.Actor
return db.CoreProject{}, err
}
updated, err := s.repo.Queries().UpdateProjectByID(ctx, db.UpdateProjectByIDParams{
ID: projectID,
Slug: normalizeSlug(input.Slug),
Name: strings.TrimSpace(input.Name),
Description: input.Description,
ID: projectID,
Slug: normalizeSlug(input.Slug),
Name: strings.TrimSpace(input.Name),
Description: input.Description,
RetentionEventsDays: input.RetentionEventsDays,
RetentionAuditDays: input.RetentionAuditDays,
RetentionWebhookDays: input.RetentionWebhookDays,
})
if err != nil {
return db.CoreProject{}, err
@@ -1038,6 +1079,7 @@ func (s *PlatformService) CreateAPIKey(ctx context.Context, actor *models.Actor,
if err != nil {
return nil, fmt.Errorf("generate api key secret: %w", err)
}
scopes := normalizeAPIKeyScopes(input.Scopes)
prefix := "prm_" + prefixSuffix
rawKey := prefix + "_" + keySuffix
secretHash := sha256.Sum256([]byte(rawKey))
@@ -1046,6 +1088,7 @@ func (s *PlatformService) CreateAPIKey(ctx context.Context, actor *models.Actor,
Name: strings.TrimSpace(input.Name),
Prefix: prefix,
SecretHash: secretHash[:],
Scopes: scopes,
CreatedByUserID: pgtype.UUID{Bytes: *actor.UserID, Valid: true},
})
if err != nil {
@@ -1062,9 +1105,27 @@ func (s *PlatformService) CreateAPIKey(ctx context.Context, actor *models.Actor,
"prefix": row.Prefix,
"secret": rawKey,
"name": row.Name,
"scopes": row.Scopes,
}, nil
}
// normalizeAPIKeyScopes dedupes the requested scopes. An omitted or empty
// list means a full-access key — the pre-scopes behaviour.
func normalizeAPIKeyScopes(input []string) []string {
if len(input) == 0 {
return []string{"admin"}
}
seen := map[string]bool{}
out := make([]string, 0, len(input))
for _, s := range input {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
return out
}
func (s *PlatformService) RevokeAPIKey(ctx context.Context, actor *models.Actor, projectID, apiKeyID uuid.UUID, requestID string) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
return err
@@ -1214,6 +1275,12 @@ func (s *PlatformService) UploadObject(ctx context.Context, actor *models.Actor,
"bucketId": bucket.ID.String(),
"key": objectKey,
}))
s.publishEvent(ctx, bucket.ProjectID, WebhookEventObjectCreated, map[string]any{
"bucket_id": bucket.ID.String(),
"object_key": objectKey,
"content_type": contentType,
"size_bytes": stored.SizeBytes,
})
return row, nil
}
@@ -1335,6 +1402,12 @@ func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor,
"oldObjectKey": oldObject.ObjectKey,
"newObjectKey": updated.ObjectKey,
}))
s.publishEvent(ctx, sourceBucket.ProjectID, WebhookEventObjectUpdated, map[string]any{
"bucket_id": destinationBucket.ID.String(),
"object_key": updated.ObjectKey,
"old_bucket_id": sourceBucket.ID.String(),
"old_object_key": oldObject.ObjectKey,
})
return updated, nil
}
@@ -1416,6 +1489,14 @@ func (s *PlatformService) CopyObject(ctx context.Context, actor *models.Actor, b
"sourceObjectKey": sourceObjectKey,
"newObjectKey": created.ObjectKey,
}))
s.publishEvent(ctx, sourceBucket.ProjectID, WebhookEventObjectCreated, map[string]any{
"bucket_id": destinationBucket.ID.String(),
"object_key": created.ObjectKey,
"content_type": created.ContentType,
"size_bytes": created.SizeBytes,
"source_bucket_id": sourceBucket.ID.String(),
"source_object_key": sourceObjectKey,
})
return created, nil
}
@@ -1441,6 +1522,10 @@ func (s *PlatformService) DeleteObject(ctx context.Context, actor *models.Actor,
"bucketId": bucketID.String(),
"key": objectKey,
}))
s.publishEvent(ctx, bucket.ProjectID, WebhookEventObjectDeleted, map[string]any{
"bucket_id": bucketID.String(),
"object_key": objectKey,
})
return nil
}
@@ -1664,6 +1749,11 @@ func (s *PlatformService) CreateDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.created", "document", row.ID.String(), map[string]any{
"collectionId": collectionID.String(),
}))
s.publishEvent(ctx, collection.ProjectID, WebhookEventDocumentCreated, map[string]any{
"collection_id": collectionID.String(),
"document_id": row.ID.String(),
"document": json.RawMessage(row.Data),
})
return row, nil
}
@@ -1688,6 +1778,11 @@ func (s *PlatformService) UpdateDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.updated", "document", documentID.String(), map[string]any{
"collectionId": collectionID.String(),
}))
s.publishEvent(ctx, collection.ProjectID, WebhookEventDocumentUpdated, map[string]any{
"collection_id": collectionID.String(),
"document_id": documentID.String(),
"document": json.RawMessage(row.Data),
})
return row, nil
}
@@ -1709,6 +1804,10 @@ func (s *PlatformService) DeleteDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.deleted", "document", documentID.String(), map[string]any{
"collectionId": collectionID.String(),
}))
s.publishEvent(ctx, collection.ProjectID, WebhookEventDocumentDeleted, map[string]any{
"collection_id": collectionID.String(),
"document_id": documentID.String(),
})
return nil
}
@@ -0,0 +1,63 @@
package services
import (
"context"
"log/slog"
"time"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
)
// RetentionSweeper deletes telemetry events, audit rows, and webhook
// deliveries that are past each project's retention window. It runs once
// shortly after boot and then hourly — a missed sweep is not a problem, the
// next tick catches it.
type RetentionSweeper struct {
repo *repositories.CoreRepository
logger *slog.Logger
interval time.Duration
}
func NewRetentionSweeper(repo *repositories.CoreRepository, logger *slog.Logger) *RetentionSweeper {
return &RetentionSweeper{repo: repo, logger: logger, interval: time.Hour}
}
func (s *RetentionSweeper) Run(ctx context.Context) {
// jarvis: initial delay — let migrations and first-party seeds settle before sweeping.
timer := time.NewTimer(time.Minute)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
s.Sweep(ctx)
timer.Reset(s.interval)
}
}
}
func (s *RetentionSweeper) Sweep(ctx context.Context) {
q := s.repo.Queries()
for _, sweep := range []struct {
name string
fn func(context.Context) (int64, error)
}{
{"events", q.SweepExpiredEvents},
{"audit_logs", q.SweepExpiredAuditLogs},
{"audit_logs_org", q.SweepExpiredOrgAuditLogs},
{"webhook_deliveries", q.SweepExpiredWebhookDeliveries},
} {
rows, err := sweep.fn(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
s.logger.Warn("retention sweep failed", "stream", sweep.name, "error", err)
continue
}
if rows > 0 {
s.logger.Info("retention sweep removed rows", "stream", sweep.name, "rows", rows)
}
}
}
+345
View File
@@ -0,0 +1,345 @@
package services
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"sort"
"strconv"
"sync"
"time"
"github.com/jackc/pgx/v5"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
)
// SettingSpec describes a settable instance key: value type, the env var used
// as fallback when no DB row exists, and a built-in default.
type SettingSpec struct {
Key string
Type string // "bool" | "int" | "string"
Secret bool
EnvKey string
Default string
}
// SettingSpecs is the registry of keys manageable in-app. Env vars stay as
// defaults — an app value wins when present.
var SettingSpecs = map[string]SettingSpec{
"auth.signup_enabled": {Key: "auth.signup_enabled", Type: "bool", EnvKey: "SIGNUP_ENABLED", Default: "false"},
"mail.from": {Key: "mail.from", Type: "string", EnvKey: "MAIL_FROM", Default: "Primora <[email protected]>"},
"mail.resend_api_key": {Key: "mail.resend_api_key", Type: "string", Secret: true, EnvKey: "RESEND_API_KEY"},
"mail.smtp_host": {Key: "mail.smtp_host", Type: "string", EnvKey: "SMTP_HOST"},
"mail.smtp_port": {Key: "mail.smtp_port", Type: "int", EnvKey: "SMTP_PORT", Default: "1025"},
"mail.smtp_user": {Key: "mail.smtp_user", Type: "string", EnvKey: "SMTP_USER"},
"mail.smtp_password": {Key: "mail.smtp_password", Type: "string", Secret: true, EnvKey: "SMTP_PASSWORD"},
"mail.smtp_secure": {Key: "mail.smtp_secure", Type: "bool", EnvKey: "SMTP_SECURE", Default: "false"},
"ratelimit.user_per_minute": {Key: "ratelimit.user_per_minute", Type: "int", EnvKey: "USER_RATE_LIMIT_PER_MINUTE", Default: "240"},
"ratelimit.api_key_per_minute": {Key: "ratelimit.api_key_per_minute", Type: "int", EnvKey: "API_KEY_RATE_LIMIT_PER_MINUTE", Default: "600"},
}
const settingsCacheTTL = 15 * time.Second
// resolvedSetting holds the stored string form: plaintext for normal keys,
// base64 ciphertext for secrets — Resolve() decrypts on read.
type resolvedSetting struct {
value string
secret bool
}
// SettingsService resolves instance settings: DB row > env var > default.
// Values cache briefly so per-request consumers (rate limits) stay cheap.
type SettingsService struct {
repo *repositories.CoreRepository
encryptor *secrets.Encryptor
mu sync.Mutex
cache map[string]resolvedSetting
cacheAt time.Time
}
func NewSettingsService(repo *repositories.CoreRepository, encryptor *secrets.Encryptor) *SettingsService {
return &SettingsService{repo: repo, encryptor: encryptor}
}
// Resolve returns the effective string value plus its source
// ("app" | "env" | "default"). Secret DB values are decrypted.
func (s *SettingsService) Resolve(ctx context.Context, key string) (value, source string, err error) {
spec, ok := SettingSpecs[key]
if !ok {
return "", "", fmt.Errorf("unknown setting: %s", key)
}
row, err := s.stored(ctx, key)
if err != nil {
return "", "", err
}
if row != nil {
raw := row.value
if row.secret {
raw, err = s.decrypt(raw)
if err != nil {
return "", "", fmt.Errorf("decrypt %s: %w", key, err)
}
}
return raw, "app", nil
}
if env := os.Getenv(spec.EnvKey); env != "" {
return env, "env", nil
}
return spec.Default, "default", nil
}
func (s *SettingsService) GetBool(ctx context.Context, key string) (bool, error) {
raw, _, err := s.Resolve(ctx, key)
if err != nil {
return false, err
}
v, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("setting %s is not a bool: %q", key, raw)
}
return v, nil
}
func (s *SettingsService) GetInt(ctx context.Context, key string) (int, error) {
raw, _, err := s.Resolve(ctx, key)
if err != nil {
return 0, err
}
v, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("setting %s is not an int: %q", key, raw)
}
return v, nil
}
// RateLimitPerMinute is the hot-path helper for the auth middleware.
func (s *SettingsService) RateLimitPerMinute(ctx context.Context, scope string) int {
key := "ratelimit.user_per_minute"
if scope == "api_key" {
key = "ratelimit.api_key_per_minute"
}
v, err := s.GetInt(ctx, key)
if err != nil {
spec := SettingSpecs[key]
fallback, _ := strconv.Atoi(spec.Default)
return fallback
}
return v
}
// ValidateAndStore type-checks the raw JSON value against the registry and
// persists it. Secret values are encrypted before they touch the DB.
func (s *SettingsService) Store(ctx context.Context, key string, raw json.RawMessage, updatedBy string) error {
spec, ok := SettingSpecs[key]
if !ok {
return fmt.Errorf("unknown setting: %s", key)
}
plain, err := normalizeSetting(spec, raw)
if err != nil {
return err
}
value := []byte(strconv.Quote(plain))
if spec.Secret {
ciphertext, err := s.encryptor.Encrypt([]byte(plain))
if err != nil {
return fmt.Errorf("encrypt %s: %w", key, err)
}
value = []byte(strconv.Quote(base64.StdEncoding.EncodeToString(ciphertext)))
}
_, err = s.repo.UpsertSetting(ctx, db.UpsertSettingParams{
Key: key,
Column2: value,
Secret: spec.Secret,
UpdatedBy: &updatedBy,
})
if err != nil {
return err
}
s.invalidate()
return nil
}
func (s *SettingsService) Delete(ctx context.Context, key string) error {
if _, ok := SettingSpecs[key]; !ok {
return fmt.Errorf("unknown setting: %s", key)
}
if err := s.repo.DeleteSetting(ctx, key); err != nil {
return err
}
s.invalidate()
return nil
}
// IsAdmin reports whether the actor holds the platform admin role
// (better-auth `user.role`), not an org/project role.
func (s *SettingsService) IsAdmin(ctx context.Context, authSubject string) bool {
role, err := s.repo.GetAuthUserRole(ctx, authSubject)
return err == nil && role == "admin"
}
func (s *SettingsService) AuthUserCount(ctx context.Context) (int64, error) {
return s.repo.CountAuthUsers(ctx)
}
// ListResolved returns every registered key with its effective state.
// Secret values are masked — never returned.
func (s *SettingsService) ListResolved(ctx context.Context) ([]ResolvedSetting, error) {
stored, err := s.loadStored(ctx)
if err != nil {
return nil, err
}
out := make([]ResolvedSetting, 0, len(SettingSpecs))
for _, key := range sortedSettingKeys() {
spec := SettingSpecs[key]
entry := ResolvedSetting{Key: key, Secret: spec.Secret, Type: spec.Type}
if row, ok := stored[key]; ok {
entry.IsSet = true
entry.Source = "app"
if !spec.Secret {
entry.Value = json.RawMessage(strconv.Quote(row.value))
}
} else if env := os.Getenv(spec.EnvKey); env != "" {
entry.Source = "env"
if !spec.Secret {
entry.Value = json.RawMessage(strconv.Quote(env))
}
} else {
entry.Source = "default"
if !spec.Secret {
entry.Value = json.RawMessage(strconv.Quote(spec.Default))
}
}
out = append(out, entry)
}
return out, nil
}
type ResolvedSetting struct {
Key string `json:"key"`
Type string `json:"type"`
Secret bool `json:"secret"`
IsSet bool `json:"is_set"`
Source string `json:"source"`
Value json.RawMessage `json:"value,omitempty"`
}
func sortedSettingKeys() []string {
keys := make([]string, 0, len(SettingSpecs))
for k := range SettingSpecs {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func (s *SettingsService) loadStored(ctx context.Context) (map[string]resolvedSetting, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.cache != nil && time.Since(s.cacheAt) < settingsCacheTTL {
return s.cache, nil
}
rows, err := s.repo.ListSettings(ctx)
if err != nil {
return nil, err
}
fresh := make(map[string]resolvedSetting, len(rows))
for _, r := range rows {
encoded, err := unwrapSettingValue(r.Value)
if err != nil {
continue // unreadable row — treat as unset rather than break consumers
}
fresh[r.Key] = resolvedSetting{value: encoded, secret: r.Secret}
}
s.cache = fresh
s.cacheAt = time.Now()
return fresh, nil
}
func (s *SettingsService) stored(ctx context.Context, key string) (*resolvedSetting, error) {
all, err := s.loadStored(ctx)
if err != nil {
// cold-cache fallback: direct lookup
row, err := s.repo.GetSetting(ctx, key)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
encoded, err := unwrapSettingValue(row.Value)
if err != nil {
return nil, err
}
return &resolvedSetting{value: encoded, secret: row.Secret}, nil
}
if row, ok := all[key]; ok {
return &row, nil
}
return nil, nil
}
func (s *SettingsService) decrypt(ciphertextB64 string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(ciphertextB64)
if err != nil {
return "", err
}
plain, err := s.encryptor.Decrypt(raw)
if err != nil {
return "", err
}
return string(plain), nil
}
func (s *SettingsService) invalidate() {
s.mu.Lock()
defer s.mu.Unlock()
s.cache = nil
}
// unwrapSettingValue decodes the JSONB column: every stored value is a JSON
// string — the plaintext for normal keys, base64 ciphertext for secrets.
func unwrapSettingValue(raw []byte) (string, error) {
var encoded string
if err := json.Unmarshal(raw, &encoded); err != nil {
return "", err
}
return encoded, nil
}
// normalizeSetting validates raw JSON against the spec type and returns the
// canonical string form stored/compared internally.
func normalizeSetting(spec SettingSpec, raw json.RawMessage) (string, error) {
switch spec.Type {
case "bool":
var v bool
if err := json.Unmarshal(raw, &v); err != nil {
return "", fmt.Errorf("%s expects a boolean", spec.Key)
}
return strconv.FormatBool(v), nil
case "int":
var v int
if err := json.Unmarshal(raw, &v); err != nil {
return "", fmt.Errorf("%s expects an integer", spec.Key)
}
if v < 0 {
return "", fmt.Errorf("%s must be >= 0", spec.Key)
}
return strconv.Itoa(v), nil
default:
var v string
if err := json.Unmarshal(raw, &v); err != nil {
return "", fmt.Errorf("%s expects a string", spec.Key)
}
if len(v) > 512 {
return "", fmt.Errorf("%s exceeds 512 chars", spec.Key)
}
return v, nil
}
}
@@ -0,0 +1,68 @@
package services
import (
"encoding/json"
"testing"
)
func TestNormalizeSetting(t *testing.T) {
t.Parallel()
boolSpec := SettingSpecs["auth.signup_enabled"]
intSpec := SettingSpecs["ratelimit.user_per_minute"]
strSpec := SettingSpecs["mail.smtp_host"]
cases := []struct {
name string
spec SettingSpec
raw string
want string
wantErr bool
}{
{name: "bool true", spec: boolSpec, raw: `true`, want: "true"},
{name: "bool false", spec: boolSpec, raw: `false`, want: "false"},
{name: "bool rejects string", spec: boolSpec, raw: `"true"`, wantErr: true},
{name: "bool rejects int", spec: boolSpec, raw: `1`, wantErr: true},
{name: "int accepts", spec: intSpec, raw: `120`, want: "120"},
{name: "int zero allowed", spec: intSpec, raw: `0`, want: "0"},
{name: "int rejects negative", spec: intSpec, raw: `-5`, wantErr: true},
{name: "int rejects float", spec: intSpec, raw: `1.5`, wantErr: true},
{name: "int rejects string", spec: intSpec, raw: `"120"`, wantErr: true},
{name: "string accepts", spec: strSpec, raw: `"smtp.example.com"`, want: "smtp.example.com"},
{name: "string rejects number", spec: strSpec, raw: `42`, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeSetting(tc.spec, json.RawMessage(tc.raw))
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
func TestSortedSettingKeysDeterministic(t *testing.T) {
t.Parallel()
first := sortedSettingKeys()
second := sortedSettingKeys()
if len(first) != len(SettingSpecs) {
t.Fatalf("expected %d keys, got %d", len(SettingSpecs), len(first))
}
for i := range first {
if first[i] != second[i] {
t.Fatalf("key order not deterministic at %d: %q vs %q", i, first[i], second[i])
}
}
}
+2 -2
View File
@@ -192,9 +192,9 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
for _, ev := range inserted {
s.hub.Broadcast(ev.ProjectID.String(), ev)
}
// A fresh error fingerprint is a new issue group — notify webhooks.
// A fresh error fingerprint is a new issue group — notify webhooks + SSE.
for _, issue := range newIssues {
s.dispatchEvent(ctx, projectID, WebhookEventIssueCreated, map[string]any{
s.publishEvent(ctx, projectID, WebhookEventIssueCreated, map[string]any{
"fingerprint": issue.Fingerprint,
"message": issue.Message,
"severity": issue.Severity,
+20 -7
View File
@@ -28,15 +28,28 @@ import (
)
const (
WebhookEventIssueCreated = "issue.created"
WebhookEventDeployMarker = "deploy.marker"
WebhookEventTest = "webhook.test"
WebhookEventIssueCreated = "issue.created"
WebhookEventDeployMarker = "deploy.marker"
WebhookEventTest = "webhook.test"
WebhookEventDocumentCreated = "document.created"
WebhookEventDocumentUpdated = "document.updated"
WebhookEventDocumentDeleted = "document.deleted"
WebhookEventObjectCreated = "object.created"
WebhookEventObjectUpdated = "object.updated"
WebhookEventObjectDeleted = "object.deleted"
)
var webhookEventTypes = map[string]bool{
WebhookEventIssueCreated: true,
WebhookEventDeployMarker: true,
WebhookEventTest: true,
WebhookEventIssueCreated: true,
WebhookEventDeployMarker: true,
WebhookEventTest: true,
WebhookEventJobRun: true,
WebhookEventDocumentCreated: true,
WebhookEventDocumentUpdated: true,
WebhookEventDocumentDeleted: true,
WebhookEventObjectCreated: true,
WebhookEventObjectUpdated: true,
WebhookEventObjectDeleted: true,
}
const webhookMaxAttempts = 3
@@ -436,7 +449,7 @@ func (s *PlatformService) CreateDeployMarker(ctx context.Context, actor *models.
if _, err := s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "deploy.marker", "deploy_marker", marker.Ref+marker.Version, meta)); err != nil {
return DeployMarker{}, err
}
s.dispatchEvent(ctx, projectID, WebhookEventDeployMarker, map[string]any{
s.publishEvent(ctx, projectID, WebhookEventDeployMarker, map[string]any{
"version": marker.Version,
"ref": marker.Ref,
"environment": marker.Environment,
+559 -3
View File
@@ -14,6 +14,10 @@ tags:
- name: Collections
- name: Databases
- name: Telemetry
- name: Integrations
- name: Webhooks
- name: Automation
- name: Instance
paths:
/health/liveness:
get:
@@ -37,6 +41,18 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ReadinessStatus"
/metrics:
get:
tags: [Health]
operationId: getMetrics
summary: Prometheus text exposition of platform HTTP metrics
responses:
"200":
description: Prometheus metrics
content:
text/plain:
schema:
type: string
/me:
get:
tags: [Platform]
@@ -52,6 +68,87 @@ paths:
$ref: "#/components/schemas/MeResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
/instance/public:
get:
tags: [Instance]
operationId: getInstancePublic
summary: Public instance state for the login page — no auth required
responses:
"200":
description: Public instance configuration
content:
application/json:
schema:
$ref: "#/components/schemas/InstancePublicConfig"
/instance/settings:
get:
tags: [Instance]
operationId: listInstanceSettings
summary: List instance settings (platform admin only)
security:
- bearerAuth: []
responses:
"200":
description: Resolved instance settings
content:
application/json:
schema:
$ref: "#/components/schemas/InstanceSettingsResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
"403":
$ref: "#/components/responses/ErrorResponse"
/instance/settings/{key}:
put:
tags: [Instance]
operationId: updateInstanceSetting
summary: Set an instance setting override (platform admin only)
security:
- bearerAuth: []
parameters:
- name: key
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateInstanceSettingRequest"
responses:
"200":
description: Setting stored
content:
application/json:
schema:
$ref: "#/components/schemas/InstanceSetting"
"400":
$ref: "#/components/responses/ErrorResponse"
"403":
$ref: "#/components/responses/ErrorResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
delete:
tags: [Instance]
operationId: deleteInstanceSetting
summary: Revert a setting to its env/default value (platform admin only)
security:
- bearerAuth: []
parameters:
- name: key
in: path
required: true
schema:
type: string
responses:
"204":
description: Setting reverted
"403":
$ref: "#/components/responses/ErrorResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
/bootstrap:
post:
tags: [Platform]
@@ -1092,6 +1189,34 @@ paths:
$ref: "#/components/responses/ErrorResponse"
"502":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/db-connections/{connectionID}/transfer:
post:
tags: [Databases]
operationId: transferDbRows
summary: Move query results from this connection to another saved connection
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/ConnectionID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/DBTransferRequest"
responses:
"200":
description: Transfer result
content:
application/json:
schema:
$ref: "#/components/schemas/DBTransferResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
"502":
$ref: "#/components/responses/ErrorResponse"
/ingest:
post:
tags: [Telemetry]
@@ -1557,6 +1682,162 @@ paths:
$ref: "#/components/schemas/DeployMarker"
"401":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/jobs:
get:
tags: [Automation]
operationId: listScheduledJobs
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
responses:
"200":
description: Scheduled jobs for the project
content:
application/json:
schema:
$ref: "#/components/schemas/ScheduledJobListResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
post:
tags: [Automation]
operationId: createScheduledJob
summary: Runs a signed POST to the job URL on a cron schedule (standard 5-field cron or @every/@hourly descriptors). Payload is signed X-Primora-Signature like webhook deliveries; an empty secret generates one returned once. HTTPS required for public targets, HTTP allowed for private/self-hosted.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateScheduledJobRequest"
responses:
"201":
description: Job created
content:
application/json:
schema:
$ref: "#/components/schemas/ScheduledJobCreateResponse"
"400":
$ref: "#/components/responses/ErrorResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
"409":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/jobs/{jobID}:
patch:
tags: [Automation]
operationId: updateScheduledJob
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/JobID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateScheduledJobRequest"
responses:
"200":
description: Job updated
content:
application/json:
schema:
$ref: "#/components/schemas/ScheduledJobCreateResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
delete:
tags: [Automation]
operationId: deleteScheduledJob
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/JobID"
responses:
"204":
description: Job deleted
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/jobs/{jobID}/runs:
get:
tags: [Automation]
operationId: listScheduledJobRuns
summary: Recent executions, newest first. History is capped at 200 runs per job.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/JobID"
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 50
responses:
"200":
description: Job run history
content:
application/json:
schema:
$ref: "#/components/schemas/ScheduledJobRunListResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/jobs/{jobID}/run:
post:
tags: [Automation]
operationId: runScheduledJob
summary: Triggers an out-of-schedule manual run. Returns the run row in "running" state.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/JobID"
responses:
"202":
description: Manual run queued
content:
application/json:
schema:
$ref: "#/components/schemas/ScheduledJobRun"
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/realtime/stream:
get:
tags: [Automation]
operationId: streamRealtimeEvents
summary: Server-sent events stream of project domain events — document/object mutations, new issues, deploy markers, job runs. EventSource clients pass `?api_key=` or `?token=` since headers cannot be set.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- name: api_key
in: query
schema:
type: string
- name: token
in: query
schema:
type: string
responses:
"200":
description: text/event-stream of {type, occurred_at, data} messages
"401":
$ref: "#/components/responses/ErrorResponse"
components:
securitySchemes:
bearerAuth:
@@ -1690,6 +1971,13 @@ components:
schema:
type: string
format: uuid
JobID:
name: jobID
in: path
required: true
schema:
type: string
format: uuid
DatabaseQuery:
name: database
in: query
@@ -1746,6 +2034,54 @@ components:
type: string
required: [code, message]
required: [status, error]
InstancePublicConfig:
type: object
properties:
signup_enabled:
type: boolean
description: Whether public sign-up is open (bootstrap always allowed)
bootstrap_required:
type: boolean
description: True when no user exists yet — first sign-up becomes instance admin
social_providers:
type: array
items:
type: string
description: Enabled OAuth providers — empty on self-hosted
required: [signup_enabled, bootstrap_required, social_providers]
InstanceSetting:
type: object
properties:
key:
type: string
type:
type: string
enum: [bool, int, string]
secret:
type: boolean
is_set:
type: boolean
description: True when an in-app override exists
source:
type: string
enum: [app, env, default]
value:
description: Effective value — never returned for secret keys
required: [key, type, secret, is_set, source]
InstanceSettingsResponse:
type: object
properties:
settings:
type: array
items:
$ref: "#/components/schemas/InstanceSetting"
required: [settings]
UpdateInstanceSettingRequest:
type: object
properties:
value:
description: JSON value matching the key's declared type
required: [value]
BootstrapRequest:
type: object
properties:
@@ -1920,6 +2256,21 @@ components:
description:
type: string
nullable: true
retention_events_days:
type: integer
minimum: 0
maximum: 3650
description: Telemetry event retention; 0 disables the sweep.
retention_audit_days:
type: integer
minimum: 0
maximum: 3650
description: Audit log retention; 0 disables the sweep.
retention_webhook_days:
type: integer
minimum: 0
maximum: 3650
description: Webhook delivery retention; 0 disables the sweep.
required: [name, slug]
Project:
type: object
@@ -1940,6 +2291,12 @@ components:
membership_role:
type: string
nullable: true
retention_events_days:
type: integer
retention_audit_days:
type: integer
retention_webhook_days:
type: integer
required: [id, organization_id, slug, name]
ProjectOverview:
type: object
@@ -2124,6 +2481,15 @@ components:
properties:
name:
type: string
scopes:
type: array
items:
type: string
enum: [ingest, read, write, admin]
description: >-
Access scopes for the key. admin unlocks everything; write unlocks
read + ingest + non-admin mutations; read and ingest unlock only
themselves. Omitted means ["admin"] (full access, legacy behaviour).
required: [name]
ApiKey:
type: object
@@ -2138,6 +2504,11 @@ components:
type: string
prefix:
type: string
scopes:
type: array
items:
type: string
enum: [ingest, read, write, admin]
last_used_at:
type: string
format: date-time
@@ -2159,6 +2530,11 @@ components:
type: string
name:
type: string
scopes:
type: array
items:
type: string
enum: [ingest, read, write, admin]
required: [id, prefix, secret, name]
CreateBucketRequest:
type: object
@@ -2591,6 +2967,52 @@ components:
output:
type: string
required: [output]
DBTransferRequest:
type: object
properties:
target_connection_id:
type: string
format: uuid
query:
type: string
description: SELECT run against the source connection.
database:
type: string
target_table:
type: string
description: Destination table for SQL targets (schema-qualified allowed).
key_pattern:
type: string
description: >-
Redis targets: SET key template with {column} placeholders, e.g.
"user:{id}".
value_column:
type: string
description: >-
Redis targets: column whose cell becomes the value. Omitted stores
the whole row as JSON.
ttl_seconds:
type: integer
minimum: 0
description: Optional EX TTL applied to each Redis SET.
limit:
type: integer
minimum: 1
maximum: 1000
default: 1000
required: [target_connection_id, query]
DBTransferResponse:
type: object
properties:
transferred:
type: integer
truncated:
type: boolean
source:
type: string
target:
type: string
required: [transferred, truncated, source, target]
IngestEvent:
type: object
properties:
@@ -2980,7 +3402,7 @@ components:
type: array
items:
type: string
description: Event filter (issue.created, deploy.marker, webhook.test). Empty means all events.
description: Event filter (issue.created, deploy.marker, webhook.test, job.run, document.*, object.*). Empty means all events.
enabled:
type: boolean
has_secret:
@@ -3002,7 +3424,7 @@ components:
type: array
items:
type: string
enum: [issue.created, deploy.marker, webhook.test]
enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
enabled:
type: boolean
required: [url]
@@ -3019,7 +3441,7 @@ components:
type: array
items:
type: string
enum: [issue.created, deploy.marker, webhook.test]
enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
enabled:
type: boolean
WebhookCreateResponse:
@@ -3108,3 +3530,137 @@ components:
type: string
format: date-time
required: [recorded_at]
ScheduledJob:
type: object
properties:
id:
type: string
format: uuid
project_id:
type: string
format: uuid
name:
type: string
schedule:
type: string
description: Cron expression (5-field) or descriptor (@hourly, @every 30m).
url:
type: string
payload:
type: object
description: Static JSON payload merged into every delivery's data field.
enabled:
type: boolean
has_secret:
type: boolean
last_run_at:
type: string
format: date-time
nullable: true
last_status:
type: string
enum: [success, failed]
nullable: true
next_run_at:
type: string
format: date-time
nullable: true
created_at:
type: string
format: date-time
required: [id, project_id, name, schedule, url, payload, enabled, has_secret]
CreateScheduledJobRequest:
type: object
properties:
name:
type: string
minLength: 2
schedule:
type: string
description: 'Cron expression, e.g. "*/15 * * * *", or descriptor like "@every 1h".'
url:
type: string
format: uri
secret:
type: string
description: HMAC key for X-Primora-Signature. Generated when omitted.
payload:
type: object
description: Static JSON payload delivered as data on every run.
enabled:
type: boolean
default: true
required: [name, schedule, url]
UpdateScheduledJobRequest:
type: object
properties:
name:
type: string
minLength: 2
schedule:
type: string
url:
type: string
format: uri
secret:
type: string
description: Replace the signing secret. Omit to keep the current one.
payload:
type: object
enabled:
type: boolean
ScheduledJobCreateResponse:
allOf:
- $ref: "#/components/schemas/ScheduledJob"
- type: object
properties:
secret:
type: string
description: Plaintext secret — present only when Primora generated it. Store it; it cannot be recovered.
ScheduledJobListResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/ScheduledJob"
required: [items]
ScheduledJobRun:
type: object
properties:
id:
type: string
format: uuid
job_id:
type: string
format: uuid
status:
type: string
enum: [running, success, failed]
triggered_by:
type: string
enum: [schedule, manual]
status_code:
type: integer
nullable: true
error:
type: string
duration_ms:
type: integer
nullable: true
started_at:
type: string
format: date-time
finished_at:
type: string
format: date-time
nullable: true
required: [id, job_id, status, triggered_by]
ScheduledJobRunListResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/ScheduledJobRun"
required: [items]
+23
View File
@@ -0,0 +1,23 @@
# @primora/cli
`primora` - command-line client for a Primora deployment.
## Build
```bash
npm run build --workspace @primora/cli # → dist/cli.js
node dist/cli.js login # or: npx primora login once published
```
## Auth
- `primora login` - session sign-in against your deployment.
- `primora login --api-key prm_…` - or export `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`.
+30 -1
View File
@@ -4,6 +4,7 @@ import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
import { cmdAuditList } from "./commands/audit.js";
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
import { cmdContext, cmdUse } from "./commands/context.js";
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js";
import {
cmdObjectsDownload,
@@ -104,6 +105,34 @@ cli
.option("--yes", "Skip the confirmation prompt")
.action(cmdKeysRevoke);
cli
.command("jobs:list", "List scheduled jobs in the current project")
.option("--project <id>", "Project override")
.action(cmdJobsList);
cli
.command("jobs:create <name>", "Create a scheduled job (secret shown once if generated)")
.option("--project <id>", "Project override")
.option("--schedule <cron>", "Cron or descriptor, e.g. '*/15 * * * *' or '@every 1h'")
.option("--url <url>", "Target endpoint (HTTPS public, HTTP private)")
.option("--payload <json>", "Static JSON payload merged into every delivery")
.option("--secret <secret>", "HMAC signing secret (generated if omitted)")
.option("--disabled", "Create paused — enable via the dashboard or API")
.action(cmdJobsCreate);
cli
.command("jobs:run <job>", "Trigger a manual run now")
.option("--project <id>", "Project override")
.action(cmdJobsRun);
cli
.command("jobs:runs <job>", "List run history for a job")
.option("--project <id>", "Project override")
.option("--limit <n>", "Page size (default 50)")
.action(cmdJobsRuns);
cli
.command("jobs:rm <job>", "Delete a scheduled job")
.option("--project <id>", "Project override")
.option("--yes", "Skip the confirmation prompt")
.action(cmdJobsRemove);
cli
.command("audit:list", "List audit events for the current project")
.option("--project <id>", "Project override")
@@ -121,7 +150,7 @@ cli.version("0.4.0");
// cac only matches the first positional token, so "orgs list" cannot be a
// command name. Fold "group verb" pairs into "group:verb" before parsing —
// `primora orgs list` and `primora orgs:list` both work.
const GROUPS = new Set(["orgs", "projects", "buckets", "objects", "keys", "audit"]);
const GROUPS = new Set(["orgs", "projects", "buckets", "objects", "keys", "jobs", "audit"]);
const argv = process.argv.slice(2);
if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
argv.splice(0, 2, `${argv[0]}:${argv[1]}`);
+149
View File
@@ -0,0 +1,149 @@
import type { ScheduledJob, ScheduledJobRun } from "@primora/api-client";
import { AutomationService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { configureClient, requireAuth, requireProject } from "../http.js";
import { formatDate, isJson, printJson, printTable, success } from "../out.js";
interface JobOptions {
project?: string;
json?: boolean;
}
function jobRow(job: ScheduledJob): string[] {
return [
job.name,
job.schedule,
job.url,
job.enabled ? "on" : "off",
job.last_status ?? "—",
job.last_run_at ? formatDate(job.last_run_at) : "never",
job.enabled && job.next_run_at ? formatDate(job.next_run_at) : "—",
job.id,
];
}
export async function cmdJobsList(options: JobOptions): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const page = await AutomationService.listScheduledJobs({ projectId });
if (isJson(options)) {
printJson(page);
} else {
printTable(
["NAME", "SCHEDULE", "URL", "ENABLED", "LAST", "LAST RUN", "NEXT RUN", "ID"],
page.items.map(jobRow),
);
}
}
export async function cmdJobsCreate(
name: string,
options: JobOptions & {
schedule?: string;
url?: string;
payload?: string;
secret?: string;
disabled?: boolean;
},
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
if (!options.schedule) throw new Error("pass --schedule, e.g. '*/15 * * * *' or '@every 1h'");
if (!options.url) throw new Error("pass --url <https endpoint>");
let payload: Record<string, unknown> | undefined;
if (options.payload) {
try {
payload = JSON.parse(options.payload) as Record<string, unknown>;
} catch {
throw new Error("--payload must be valid JSON");
}
}
const projectId = requireProject(cfg, options.project);
const job = await AutomationService.createScheduledJob({
projectId,
requestBody: {
name,
schedule: options.schedule,
url: options.url,
secret: options.secret || undefined,
payload,
enabled: !options.disabled,
},
});
if (isJson(options)) {
printJson(job);
return;
}
success(`created ${job.name} (${job.id})`);
if (job.secret) {
process.stderr.write(`signing secret (shown once): ${job.secret}\n`);
}
}
export async function cmdJobsRemove(jobId: string, options: JobOptions & { yes?: boolean }): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
await AutomationService.deleteScheduledJob({ projectId, jobId });
if (isJson(options)) {
printJson({ deleted: jobId });
} else {
success(`deleted ${jobId}`);
}
}
export async function cmdJobsRun(jobId: string, options: JobOptions): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const run = await AutomationService.runScheduledJob({ projectId, jobId });
if (isJson(options)) {
printJson(run);
} else {
success(`run queued (${run.id}) — status ${run.status}`);
}
}
function runRow(run: ScheduledJobRun): string[] {
return [
formatDate(run.started_at),
run.status,
run.triggered_by,
run.status_code != null ? String(run.status_code) : "—",
run.duration_ms != null ? `${run.duration_ms}ms` : "—",
run.error || "—",
];
}
export async function cmdJobsRuns(
jobId: string,
options: JobOptions & { limit?: string },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const page = await AutomationService.listScheduledJobRuns({
projectId,
jobId,
limit: options.limit ? Number(options.limit) : 50,
});
if (isJson(options)) {
printJson(page);
} else {
printTable(["STARTED", "STATUS", "TRIGGER", "HTTP", "DURATION", "ERROR"], page.items.map(runRow));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@primora/desktop",
"version": "0.5.0",
"version": "0.7.1",
"private": true,
"description": "Primora desktop shell — thin Tauri client for a self-hosted deployment",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Primora",
"version": "0.5.0",
"version": "0.7.1",
"identifier": "dev.tdvorak.primora",
"build": {
"frontendDist": "../ui"
+143 -7
View File
@@ -4,6 +4,7 @@ import {
CreateInvitationRequest,
CreateOrganizationRequest,
HealthService,
InstanceService,
OrganizationsService,
PlatformService,
ProjectsService,
@@ -14,9 +15,11 @@ import {
UpdateOrganizationRequest,
UpdateProjectMemberRoleRequest,
UpdateProjectRequest,
CreateApiKeyRequest,
CopyBucketObjectRequest,
type Bucket,
type BucketObject,
type InstanceSetting,
type OrganizationSummary,
type ProjectSummary,
} from "@primora/api-client";
@@ -38,6 +41,7 @@ import {
import {
AuditPage,
AuthPage,
AutomationPage,
type AuthUser,
CollectionsPage,
DatabasesPage,
@@ -57,6 +61,7 @@ import {
IconStorage,
IconDatabases,
IconTelemetry,
IconZap,
IconCollections,
IconAudit,
IconAuth,
@@ -138,6 +143,7 @@ export default function App() {
const [memberMessage, setMemberMessage] = createSignal("");
const [apiKeySecret, setApiKeySecret] = createSignal("");
const [collectionsMessage, setCollectionsMessage] = createSignal("");
const [instanceMessage, setInstanceMessage] = createSignal("");
const [selectedOrganizationID, setSelectedOrganizationID] = createSignal<string | undefined>();
const [selectedProjectID, setSelectedProjectID] = createSignal<string | undefined>();
@@ -180,7 +186,14 @@ export default function App() {
});
const [projectInput, setProjectInput] = createSignal({ name: "", slug: "", description: "" });
const [projectEditInput, setProjectEditInput] = createSignal({ name: "", slug: "", description: "" });
const [projectEditInput, setProjectEditInput] = createSignal({
name: "",
slug: "",
description: "",
retentionEventsDays: "",
retentionAuditDays: "",
retentionWebhookDays: "",
});
const [organizationInput, setOrganizationInput] = createSignal({ name: "", slug: "" });
const [organizationEditInput, setOrganizationEditInput] = createSignal({ name: "", slug: "" });
const [invitationInput, setInvitationInput] = createSignal({
@@ -190,6 +203,7 @@ export default function App() {
projectRole: CreateInvitationRequest.projectRole.DEVELOPER as string,
});
const [apiKeyName, setApiKeyName] = createSignal("Frontend key");
const [apiKeyScopes, setApiKeyScopes] = createSignal<string[]>(["admin"]);
const [bucketInput, setBucketInput] = createSignal({
name: "",
slug: "",
@@ -226,6 +240,47 @@ export default function App() {
}
});
// Public instance state — fetched before login so the auth page can hide
// sign-up/social options the instance does not offer.
const [instancePublic] = createResource(async () => {
if (isDemo) return { signup_enabled: true, bootstrap_required: false, social_providers: [] };
try {
return await InstanceService.getInstancePublic();
} catch {
// Endpoint unreachable (backend down or pre-migration): keep sign-in
// visible rather than lock the page out.
return undefined;
}
});
const canSignUp = createMemo(() => {
const cfg = instancePublic();
if (!cfg) return true; // unknown — let the backend enforce
return cfg.bootstrap_required || cfg.signup_enabled;
});
const enabledSocialProviders = createMemo(() => instancePublic()?.social_providers ?? []);
const isPlatformAdmin = createMemo(() => {
const role = (session()?.data?.user as { role?: string } | undefined)?.role;
return isDemo || role === "admin";
});
const [instanceSettings, { refetch: refetchInstanceSettings }] = createResource(
() => (activeView() === "settings" && isPlatformAdmin() && session()?.data ? true : null),
async (): Promise<InstanceSetting[]> => {
if (isDemo) return demoService.listInstanceSettings();
return (await InstanceService.listInstanceSettings()).settings;
},
);
// Clamp the auth mode to what the instance permits.
createEffect(() => {
const cfg = instancePublic();
if (!cfg || isDemo) return;
if (cfg.bootstrap_required) setMode("sign-up");
else if (!cfg.signup_enabled) setMode("sign-in");
});
const [platform, { refetch: refetchPlatform }] = createResource(
() => session()?.data?.user.id ?? null,
async () => {
@@ -258,7 +313,13 @@ export default function App() {
},
);
const availableProjects = createMemo<ProjectSummary[]>(() => {
type ProjectListItem = ProjectSummary & {
retentionEventsDays?: number;
retentionAuditDays?: number;
retentionWebhookDays?: number;
};
const availableProjects = createMemo<ProjectListItem[]>(() => {
const items = organizationProjects();
if (!items) return activeOrganization()?.projects ?? [];
return items.map((project) => ({
@@ -267,10 +328,13 @@ export default function App() {
slug: project.slug,
description: project.description ?? undefined,
membershipRole: project.membership_role ?? undefined,
retentionEventsDays: project.retention_events_days,
retentionAuditDays: project.retention_audit_days,
retentionWebhookDays: project.retention_webhook_days,
}));
});
const activeProject = createMemo<ProjectSummary | undefined>(() =>
const activeProject = createMemo<ProjectListItem | undefined>(() =>
availableProjects().find((item) => item.id === selectedProjectID()),
);
const canUpdateOrganization = createMemo(() => {
@@ -292,6 +356,7 @@ export default function App() {
const storagePending = createMemo(() => isPendingPrefix("storage-"));
const collectionPending = createMemo(() => isPendingPrefix("collection-"));
const documentPending = createMemo(() => isPendingPrefix("document-"));
const instancePending = createMemo(() => isPendingPrefix("instance-"));
const [apiKeys, { refetch: refetchAPIKeys }] = createResource(
() => activeProject()?.id ?? null,
@@ -547,8 +612,15 @@ export default function App() {
const project = activeProject();
setProjectEditInput(
project
? { name: project.name, slug: project.slug, description: project.description ?? "" }
: { name: "", slug: "", description: "" },
? {
name: project.name,
slug: project.slug,
description: project.description ?? "",
retentionEventsDays: project.retentionEventsDays?.toString() ?? "",
retentionAuditDays: project.retentionAuditDays?.toString() ?? "",
retentionWebhookDays: project.retentionWebhookDays?.toString() ?? "",
}
: { name: "", slug: "", description: "", retentionEventsDays: "", retentionAuditDays: "", retentionWebhookDays: "" },
);
});
@@ -664,7 +736,7 @@ export default function App() {
try {
if (mode() === "sign-up") {
await authClient.signUp.email({ email: email(), password: password(), name: name() });
setAuthMessage("Account created. Check Mailpit (or your SMTP provider) for the verification email.");
setAuthMessage("Account created — signing you in.");
} else {
await authClient.signIn.email({ email: email(), password: password() });
}
@@ -713,6 +785,12 @@ export default function App() {
setProjectMessage("");
try {
const trimmed = projectEditInput().description.trim();
const parseRetention = (value: string): number | undefined => {
const raw = value.trim();
if (raw === "") return undefined;
const n = Number(raw);
return Number.isFinite(n) ? Math.min(3650, Math.max(0, Math.trunc(n))) : undefined;
};
if (isDemo) {
await demoService.updateProject({ requestBody: { ...projectEditInput(), description: trimmed || null } });
} else {
@@ -722,6 +800,9 @@ export default function App() {
name: projectEditInput().name,
slug: projectEditInput().slug,
description: trimmed.length > 0 ? trimmed : null,
retention_events_days: parseRetention(projectEditInput().retentionEventsDays),
retention_audit_days: parseRetention(projectEditInput().retentionAuditDays),
retention_webhook_days: parseRetention(projectEditInput().retentionWebhookDays),
} as UpdateProjectRequest,
});
}
@@ -1001,7 +1082,14 @@ export default function App() {
try {
const result = isDemo
? await demoService.createApiKey({ requestBody: { name: apiKeyName() } })
: await ProjectsService.createApiKey({ projectId: projectID, requestBody: { name: apiKeyName() } });
: await ProjectsService.createApiKey({
projectId: projectID,
requestBody: {
name: apiKeyName(),
scopes: apiKeyScopes() as CreateApiKeyRequest["scopes"],
},
});
setApiKeyScopes(["admin"]);
await refetchAPIKeys();
await refetchAuditLogs();
await refreshProjectOverviewSnapshot();
@@ -1388,6 +1476,32 @@ export default function App() {
});
}
async function saveInstanceSetting(key: string, value: unknown) {
await runPending("instance-", async () => {
setInstanceMessage("");
try {
await InstanceService.updateInstanceSetting({ key, requestBody: { value } });
await refetchInstanceSettings();
setInstanceMessage("Setting saved.");
} catch (error) {
setInstanceMessage(getErrorMessage(error, "Failed to save setting"));
}
});
}
async function resetInstanceSetting(key: string) {
await runPending("instance-", async () => {
setInstanceMessage("");
try {
await InstanceService.deleteInstanceSetting({ key });
await refetchInstanceSettings();
setInstanceMessage(`"${key}" reverted to its env/default value.`);
} catch (error) {
setInstanceMessage(getErrorMessage(error, "Failed to reset setting"));
}
});
}
async function startSocial(provider: "github" | "google" | "discord" | "microsoft") {
if (isDemo) {
setAuthMessage("Demo mode is active — social auth is simulated.");
@@ -1416,6 +1530,7 @@ export default function App() {
{ id: "nav-members", label: "Go to Members", category: "Navigate", icon: <IconMembers class="w-4 h-4" />, keywords: ["team", "users", "invite"], action: () => setActiveView("members") },
{ id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: <IconDatabases class="w-4 h-4" />, keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") },
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
{ id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: <IconZap class="w-4 h-4" />, keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") },
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
@@ -1456,6 +1571,9 @@ export default function App() {
name={name()}
authMessage={authMessage()}
authPending={authPending()}
canSignUp={canSignUp()}
bootstrapRequired={instancePublic()?.bootstrap_required ?? false}
enabledProviders={enabledSocialProviders()}
onModeChange={setMode}
onEmailChange={setEmail}
onPasswordChange={setPassword}
@@ -1646,6 +1764,14 @@ export default function App() {
/>
</Show>
<Show when={activeView() === "automation"}>
<AutomationPage
projectID={activeProject()?.id}
canManage={canUpdateProject()}
demoMode={isDemo}
/>
</Show>
<Show when={activeView() === "storage"}>
<StoragePage
buckets={buckets()}
@@ -1764,8 +1890,15 @@ export default function App() {
<Show when={activeView() === "settings"}>
<SettingsPage
isPlatformAdmin={isPlatformAdmin()}
instanceSettings={instanceSettings()}
instancePending={instancePending() || instanceSettings.loading}
instanceMessage={instanceMessage()}
onSaveInstanceSetting={(key, value) => void saveInstanceSetting(key, value)}
onResetInstanceSetting={(key) => void resetInstanceSetting(key)}
apiKeys={apiKeys()}
apiKeyName={apiKeyName()}
apiKeyScopes={apiKeyScopes()}
apiKeySecret={apiKeySecret()}
apiKeyMessage={apiKeyMessage()}
apiKeyPending={apiKeyPending()}
@@ -1776,6 +1909,9 @@ export default function App() {
workspacePending={workspacePending()}
hasActiveOrganization={!!activeOrganization()}
onApiKeyNameChange={setApiKeyName}
onApiKeyScopeToggle={(scope) =>
setApiKeyScopes((c) => (c.includes(scope) ? c.filter((s) => s !== scope) : [...c, scope]))
}
onCreateApiKey={createApiKey}
onDeleteApiKey={revokeApiKey}
onOrganizationInputChange={(field, value) => setOrganizationInput((c) => ({ ...c, [field]: value }))}
+5 -1
View File
@@ -10,6 +10,7 @@ import {
IconDatabases,
IconTelemetry,
IconIntegrations,
IconZap,
IconAudit,
IconAuth,
IconSettings,
@@ -30,6 +31,7 @@ export type ViewType =
| "databases"
| "telemetry"
| "integrations"
| "automation"
| "auth"
| "audit"
| "settings";
@@ -46,6 +48,7 @@ const projectNav: NavEntry[] = [
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
{ id: "storage", label: "Storage", icon: IconStorage },
{ id: "collections", label: "Collections", icon: IconCollections },
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
@@ -53,7 +56,7 @@ const projectNav: NavEntry[] = [
];
// Instagram-style bottom bar caps at five slots — the rest stay reachable via Menu.
const bottomNav: NavEntry[] = projectNav.filter((e) => !["collections", "telemetry", "integrations"].includes(e.id));
const bottomNav: NavEntry[] = projectNav.filter((e) => !["collections", "telemetry", "integrations", "automation"].includes(e.id));
const workspaceNav: NavEntry[] = [
{ id: "projects", label: "Projects", icon: IconProjects },
@@ -92,6 +95,7 @@ const viewTitle: Record<ViewType, string> = {
databases: "Databases",
telemetry: "Telemetry",
integrations: "Integrations",
automation: "Automation",
auth: "Authentication",
audit: "Audit log",
settings: "Settings",
+3 -2
View File
@@ -13,16 +13,17 @@ const sizeClasses = {
};
export function Input(props: InputProps) {
const [local, rest] = splitProps(props, ["label", "error", "size", "class"]);
const [local, rest] = splitProps(props, ["label", "error", "size", "class", "id"]);
const size = () => local.size ?? "md";
return (
<div class="w-full">
<Show when={local.label}>
<label class="label">{local.label}</label>
<label class="label" for={local.id}>{local.label}</label>
</Show>
<input
id={local.id}
class={`input ${sizeClasses[size()]} ${local.error ? "border-error focus:border-error focus:ring-error/20" : ""} ${local.class ?? ""}`}
{...rest}
/>
+6 -2
View File
@@ -1,8 +1,12 @@
import { createAuthClient } from "better-auth/solid";
import { adminClient } from "better-auth/client/plugins";
// Use full URL for dev mode, relative path for production
const baseURL = import.meta.env.VITE_AUTH_BASE_URL ?? "http://localhost/auth";
// Same-origin default — nginx routes /auth to the auth service at whatever
// port or domain serves the app. better-auth requires an absolute baseURL, so
// resolve it against the runtime origin; VITE_AUTH_BASE_URL overrides for
// split-origin setups.
const baseURL =
import.meta.env.VITE_AUTH_BASE_URL ?? new URL("/auth", window.location.origin).href;
export const authClient = createAuthClient({
baseURL,
+167 -9
View File
@@ -8,6 +8,8 @@ import {
OrganizationInvitation,
OrganizationMember,
ProjectMember,
ScheduledJob,
ScheduledJobRun,
WebhookDelivery,
} from "@primora/api-client";
import type {
@@ -21,6 +23,7 @@ import type {
Document,
IntegrationAnalytics,
IntegrationTestResult,
InstanceSetting,
MeResponse,
MetricPoint,
OrganizationSummary,
@@ -32,6 +35,7 @@ import type {
TelemetryStats,
Webhook,
WebhookCreateResponse,
ScheduledJobCreateResponse,
} from "@primora/api-client";
const STORAGE_KEY = "primora_demo_mode";
@@ -340,27 +344,27 @@ let dbConnections: DBConnection[] = [
{
id: "dbc-1",
project_id: "proj-1",
name: "platform-postgres",
name: "warehouse",
db_type: "postgres",
host: "postgres",
host: "db.example.internal",
port: 5432,
database: "primora",
username: "primora",
ssl: null,
is_managed: true,
database: "analytics",
username: "primora_read",
ssl: true,
is_managed: false,
has_password: true,
},
{
id: "dbc-2",
project_id: "proj-1",
name: "platform-dragonfly",
name: "cache",
db_type: "redis",
host: "dragonfly",
host: "redis.example.internal",
port: 6379,
database: "",
username: "",
ssl: null,
is_managed: true,
is_managed: false,
has_password: false,
},
];
@@ -485,6 +489,74 @@ const demoDeliveries: WebhookDelivery[] = [
},
];
const demoJobs: ScheduledJob[] = [
{
id: "demo-job-1",
project_id: "demo-project-1",
name: "nightly-rollup",
schedule: "0 3 * * *",
url: "https://api.example.com/jobs/rollup",
payload: { task: "rollup", window: "24h" },
enabled: true,
has_secret: true,
last_run_at: new Date(Date.now() - 7 * 3_600_000).toISOString(),
last_status: ScheduledJob.last_status.SUCCESS,
next_run_at: new Date(Date.now() + 17 * 3_600_000).toISOString(),
created_at: new Date(Date.now() - 14 * 86_400_000).toISOString(),
},
{
id: "demo-job-2",
project_id: "demo-project-1",
name: "healthcheck-ping",
schedule: "@every 15m",
url: "https://status.example.com/ping",
payload: {},
enabled: false,
has_secret: false,
last_run_at: new Date(Date.now() - 3 * 86_400_000).toISOString(),
last_status: ScheduledJob.last_status.FAILED,
created_at: new Date(Date.now() - 30 * 86_400_000).toISOString(),
},
];
const demoJobRuns: Record<string, ScheduledJobRun[]> = {
"demo-job-1": [
{
id: "demo-run-1",
job_id: "demo-job-1",
status: ScheduledJobRun.status.SUCCESS,
triggered_by: ScheduledJobRun.triggered_by.SCHEDULE,
status_code: 200,
duration_ms: 214,
started_at: new Date(Date.now() - 7 * 3_600_000).toISOString(),
finished_at: new Date(Date.now() - 7 * 3_600_000 + 214).toISOString(),
},
{
id: "demo-run-2",
job_id: "demo-job-1",
status: ScheduledJobRun.status.FAILED,
triggered_by: ScheduledJobRun.triggered_by.MANUAL,
status_code: 500,
error: "endpoint returned 500",
duration_ms: 1024,
started_at: new Date(Date.now() - 31 * 3_600_000).toISOString(),
finished_at: new Date(Date.now() - 31 * 3_600_000 + 1024).toISOString(),
},
],
"demo-job-2": [
{
id: "demo-run-3",
job_id: "demo-job-2",
status: ScheduledJobRun.status.FAILED,
triggered_by: ScheduledJobRun.triggered_by.SCHEDULE,
error: "connection refused",
duration_ms: 12,
started_at: new Date(Date.now() - 3 * 86_400_000).toISOString(),
finished_at: new Date(Date.now() - 3 * 86_400_000 + 12).toISOString(),
},
],
};
class DemoService {
private delay(ms = 250) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -531,9 +603,25 @@ class DemoService {
name: p.name,
description: p.description ?? null,
membership_role: p.membershipRole ?? null,
retention_events_days: 90,
retention_audit_days: 90,
retention_webhook_days: 90,
}));
}
async listAuthUsers() { await this.delay(); return authUsers; }
// Mirrors InstanceService.listInstanceSettings — representative registry
// slice so demo mode can show the Instance settings tab.
async listInstanceSettings(): Promise<InstanceSetting[]> {
await this.delay();
return [
{ key: "auth.signup_enabled", type: "bool", secret: false, is_set: false, source: "default", value: false },
{ key: "mail.from", type: "string", secret: false, is_set: false, source: "env", value: "Primora <[email protected]>" },
{ key: "mail.smtp_host", type: "string", secret: false, is_set: true, source: "app", value: "mailpit" },
{ key: "mail.smtp_password", type: "string", secret: true, is_set: false, source: "default" },
{ key: "ratelimit.user_per_minute", type: "int", secret: false, is_set: false, source: "default", value: 240 },
] as InstanceSetting[];
}
async listCollections() { await this.delay(); return collections; }
async listDocuments() { await this.delay(); return documents; }
@@ -1230,6 +1318,76 @@ class DemoService {
return delivery;
}
async listScheduledJobs(): Promise<{ items: ScheduledJob[] }> {
await this.delay();
return { items: demoJobs };
}
async createScheduledJob(data: {
requestBody?: { name?: string; schedule?: string; url?: string; secret?: string; payload?: Record<string, unknown>; enabled?: boolean };
}): Promise<ScheduledJobCreateResponse> {
await this.delay();
const generated = !data.requestBody?.secret;
const job: ScheduledJob = {
id: `demo-job-${demoJobs.length + 1}`,
project_id: "demo-project-1",
name: data.requestBody?.name ?? "untitled",
schedule: data.requestBody?.schedule ?? "0 * * * *",
url: data.requestBody?.url ?? "https://example.com",
payload: (data.requestBody?.payload ?? {}) as Record<string, never>,
enabled: data.requestBody?.enabled ?? true,
has_secret: true,
last_status: null,
next_run_at: new Date(Date.now() + 3_600_000).toISOString(),
created_at: new Date().toISOString(),
};
demoJobs.push(job);
return { ...job, secret: generated ? "demo-secret-" + Math.random().toString(36).slice(2, 18) : undefined };
}
async updateScheduledJob(data: { jobId?: string; requestBody?: Partial<ScheduledJob> }): Promise<ScheduledJob> {
await this.delay();
const job = demoJobs.find((j) => j.id === data.jobId);
if (job && data.requestBody) {
Object.assign(job, data.requestBody);
}
return job as ScheduledJob;
}
async deleteScheduledJob(data: { jobId?: string }) {
await this.delay();
const idx = demoJobs.findIndex((j) => j.id === data.jobId);
if (idx >= 0) demoJobs.splice(idx, 1);
return {};
}
async listScheduledJobRuns(data: { jobId?: string; limit?: number }): Promise<{ items: ScheduledJobRun[] }> {
await this.delay();
return { items: (demoJobRuns[data.jobId ?? ""] ?? []).slice(0, data.limit ?? 50) };
}
async runScheduledJob(data: { jobId?: string }): Promise<ScheduledJobRun> {
await this.delay();
const jobID = data.jobId ?? "demo-job-1";
const run: ScheduledJobRun = {
id: `demo-run-${Date.now()}`,
job_id: jobID,
status: ScheduledJobRun.status.SUCCESS,
triggered_by: ScheduledJobRun.triggered_by.MANUAL,
status_code: 200,
duration_ms: Math.floor(Math.random() * 800) + 50,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
};
(demoJobRuns[jobID] ??= []).unshift(run);
const job = demoJobs.find((j) => j.id === jobID);
if (job) {
job.last_run_at = run.started_at;
job.last_status = ScheduledJob.last_status.SUCCESS;
}
return run;
}
async createDeployMarker(data: {
requestBody?: { version?: string; ref?: string; environment?: string; note?: string };
}): Promise<DeployMarker> {
+594
View File
@@ -0,0 +1,594 @@
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js";
import {
AutomationService,
OpenAPI,
type ScheduledJob,
type ScheduledJobRun,
} from "@primora/api-client";
import { fetchApiToken } from "../lib/auth-client";
import { demoService } from "../lib/demo-mode";
import { Badge } from "../components/Badge";
import { Modal } from "../components/Modal";
import { Input, Textarea } from "../components/Input";
import {
IconPlus,
IconRefresh,
IconTrash,
IconPlay,
IconChevronRight,
IconCheck,
IconCopy,
IconZap,
} from "../components/Icons";
interface AutomationPageProps {
projectID?: string;
canManage: boolean;
demoMode: boolean;
}
type Tab = "schedules" | "events";
interface RealtimeEvent {
type: string;
occurred_at: string;
data: Record<string, unknown>;
}
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
const svc = (demo: boolean) =>
(demo ? (demoService as unknown as typeof AutomationService) : AutomationService);
const fmtTime = (ts?: string | null) => (ts ? new Date(ts).toLocaleString() : "—");
const statusVariant = (s?: string | null) =>
s === "success" ? "success" : s === "failed" ? "error" : s === "running" ? "warning" : "neutral";
const eventVariant = (t?: string) =>
t?.startsWith("document.")
? "primary"
: t?.startsWith("object.")
? "success"
: t === "job.run"
? "warning"
: t === "issue.created"
? "error"
: "neutral";
const DEMO_EVENT_TYPES = ["document.created", "object.created", "job.run", "deploy.marker", "issue.created"];
export function AutomationPage(props: AutomationPageProps) {
const [tab, setTab] = createSignal<Tab>("schedules");
const [jobs, setJobs] = createSignal<ScheduledJob[]>([]);
const [runs, setRuns] = createSignal<Record<string, ScheduledJobRun[]>>({});
const [expandedJob, setExpandedJob] = createSignal<string>();
const [showCreate, setShowCreate] = createSignal(false);
const [jobForm, setJobForm] = createSignal({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true });
const [createdSecret, setCreatedSecret] = createSignal("");
const [loading, setLoading] = createSignal(false);
const [busy, setBusy] = createSignal<string>();
const [message, setMessage] = createSignal("");
const [error, setError] = createSignal("");
const [events, setEvents] = createSignal<RealtimeEvent[]>([]);
const [live, setLive] = createSignal(false);
const [paused, setPaused] = createSignal(false);
const [typeFilter, setTypeFilter] = createSignal("");
const refresh = async () => {
if (!props.projectID) return;
setLoading(true);
setError("");
try {
const res = await svc(props.demoMode).listScheduledJobs({ projectId: props.projectID });
setJobs(res.items ?? []);
} catch (e) {
setError(err(e));
} finally {
setLoading(false);
}
};
const loadRuns = async (jobID: string) => {
if (!props.projectID) return;
try {
const res = await svc(props.demoMode).listScheduledJobRuns({ projectId: props.projectID, jobId: jobID, limit: 50 });
setRuns((c) => ({ ...c, [jobID]: res.items ?? [] }));
} catch {
/* non-fatal */
}
};
const toggleRuns = (jobID: string) => {
if (expandedJob() === jobID) {
setExpandedJob(undefined);
return;
}
setExpandedJob(jobID);
void loadRuns(jobID);
};
createEffect(() => {
if (props.projectID) void refresh();
});
/* Live events — SSE against /realtime/stream; demo mode fakes a feed. */
createEffect(() => {
if (!props.projectID || tab() !== "events") return;
if (props.demoMode) {
let i = 0;
const timer = setInterval(() => {
if (paused()) return;
const type = DEMO_EVENT_TYPES[i++ % DEMO_EVENT_TYPES.length];
const data: Record<string, unknown> =
type === "job.run"
? { job_name: "nightly-rollup", status: "success", duration_ms: 42 + i }
: type.startsWith("document.")
? { collection_id: "demo-col-1", document_id: `doc-${i}`, document: { title: `Note ${i}` } }
: type.startsWith("object.")
? { bucket_id: "demo-bucket-1", object_key: `uploads/file-${i}.png`, size_bytes: 4096 * i }
: type === "issue.created"
? { fingerprint: `fp-${i}`, message: "TypeError: demo failure", severity: "error" }
: { version: `0.7.${i}`, environment: "production" };
setEvents((prev) => [{ type, occurred_at: new Date().toISOString(), data }, ...prev].slice(0, 200));
}, 2500);
setLive(true);
onCleanup(() => {
clearInterval(timer);
setLive(false);
});
return;
}
let es: EventSource | null = null;
let cancelled = false;
void fetchApiToken().then((token) => {
if (cancelled || !token || !props.projectID) return;
const url = `${OpenAPI.BASE}/projects/${props.projectID}/realtime/stream?token=${encodeURIComponent(token)}`;
es = new EventSource(url);
es.addEventListener("event", (m) => {
setLive(true);
if (paused()) return;
try {
const ev = JSON.parse((m as MessageEvent).data) as RealtimeEvent;
setEvents((prev) => [ev, ...prev].slice(0, 200));
} catch {
/* ignore malformed frames */
}
});
es.onerror = () => setLive(false);
es.onopen = () => setLive(true);
});
onCleanup(() => {
cancelled = true;
es?.close();
setLive(false);
});
});
const run = async (key: string, task: () => Promise<unknown>, done?: string) => {
setBusy(key);
setMessage("");
setError("");
try {
await task();
if (done) setMessage(done);
await refresh();
} catch (e) {
setError(err(e));
} finally {
setBusy(undefined);
}
};
const createJob = (e: Event) => {
e.preventDefault();
const form = jobForm();
let payload: Record<string, unknown> | undefined;
if (form.payload.trim()) {
try {
payload = JSON.parse(form.payload) as Record<string, unknown>;
} catch {
setError("Payload must be valid JSON");
return;
}
}
void run("create-job", async () => {
const created = await svc(props.demoMode).createScheduledJob({
projectId: props.projectID!,
requestBody: {
name: form.name.trim(),
schedule: form.schedule.trim(),
url: form.url.trim(),
secret: form.secret.trim() || undefined,
payload,
enabled: form.enabled,
},
});
if (created.secret) setCreatedSecret(created.secret);
setJobForm({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true });
setShowCreate(false);
}, "Schedule created");
};
const toggleEnabled = (job: ScheduledJob) =>
void run(`toggle-${job.id}`, () =>
svc(props.demoMode).updateScheduledJob({
projectId: props.projectID!,
jobId: job.id,
requestBody: { enabled: !job.enabled },
}),
);
const runNow = (job: ScheduledJob) =>
void run(
`run-${job.id}`,
() =>
svc(props.demoMode).runScheduledJob({ projectId: props.projectID!, jobId: job.id }),
`Run queued for ${job.name}`,
);
const deleteJob = (job: ScheduledJob) =>
void run(
`del-${job.id}`,
() => svc(props.demoMode).deleteScheduledJob({ projectId: props.projectID!, jobId: job.id }),
`Deleted ${job.name}`,
);
const filteredEvents = () => {
const f = typeFilter();
return f ? events().filter((e) => e.type === f) : events();
};
const eventTypes = () => [...new Set(events().map((e) => e.type))];
return (
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">Automation</h1>
<p class="page-description">Scheduled jobs and the live event stream for this project.</p>
</div>
<button class="btn btn-secondary" onClick={() => void refresh()} disabled={loading()}>
<IconRefresh class="w-4 h-4" />
Refresh
</button>
</div>
<Show when={message()}>
<div class="message message-success mb-4">{message()}</div>
</Show>
<Show when={error()}>
<div class="message message-error mb-4">{error()}</div>
</Show>
<Show when={createdSecret()}>
<div class="message message-warning mb-4">
<div class="flex-1 min-w-0">
<strong>Job signing secret — shown once.</strong>
<code class="block mt-1" style="word-break: break-all">{createdSecret()}</code>
</div>
<button class="btn btn-ghost btn-sm" onClick={() => navigator.clipboard?.writeText(createdSecret())} title="Copy secret">
<IconCopy class="w-4 h-4" />
</button>
<button class="btn btn-ghost btn-sm" onClick={() => setCreatedSecret("")} title="Dismiss">
<IconCheck class="w-4 h-4" />
</button>
</div>
</Show>
<div class="tabs mb-4">
<button class={`tab ${tab() === "schedules" ? "active" : ""}`} onClick={() => setTab("schedules")}>
Schedules
</button>
<button class={`tab ${tab() === "events" ? "active" : ""}`} onClick={() => setTab("events")}>
Live events
</button>
</div>
<Show when={tab() === "schedules"}>
<div class="card card-flush">
<div class="card-header">
<div class="flex-1">
<div class="card-header-title">Scheduled jobs</div>
<div class="card-header-description">
Cron-driven signed POSTs to your endpoints. Deliveries carry{" "}
<code>X-Primora-Signature</code> (HMAC-SHA256) — same scheme as webhooks. Missed
occurrences collapse into a single run.
</div>
</div>
<Show when={props.canManage}>
<button class="btn btn-primary btn-sm" onClick={() => setShowCreate(true)}>
<IconPlus class="w-4 h-4" />
New schedule
</button>
</Show>
</div>
<Show
when={jobs().length > 0}
fallback={
<p class="text-text-2 text-sm p-4">
No schedules yet. Add one to ping an endpoint on a cron — backups, rollups, report
generation.
</p>
}
>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Schedule</th>
<th>Target</th>
<th class="hidden md:table-cell">Next run</th>
<th class="hidden md:table-cell">Last run</th>
<th>Enabled</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<For each={jobs()}>
{(job) => (
<>
<tr>
<td class="font-medium">{job.name}</td>
<td>
<code class="text-xs">{job.schedule}</code>
</td>
<td class="text-text-2" style="max-width:14rem;overflow:hidden;text-overflow:ellipsis">
{job.url}
</td>
<td class="hidden md:table-cell text-text-2">{job.enabled ? fmtTime(job.next_run_at) : "—"}</td>
<td class="hidden md:table-cell">
<Show when={job.last_run_at} fallback={<span class="text-text-2">never</span>}>
<Badge variant={statusVariant(job.last_status)}>{job.last_status}</Badge>{" "}
<span class="text-text-2 text-xs">{fmtTime(job.last_run_at)}</span>
</Show>
</td>
<td>
<Show when={props.canManage} fallback={<Badge variant={job.enabled ? "success" : "neutral"}>{job.enabled ? "on" : "off"}</Badge>}>
<button
class={`badge ${job.enabled ? "badge-success" : ""} cursor-pointer border-0`}
onClick={() => toggleEnabled(job)}
disabled={busy() === `toggle-${job.id}`}
title={job.enabled ? "Disable" : "Enable"}
>
{job.enabled ? "on" : "off"}
</button>
</Show>
</td>
<td style="text-align:right;white-space:nowrap">
<button class="btn btn-ghost btn-sm" onClick={() => toggleRuns(job.id)}>
<IconChevronRight class={`w-4 h-4 ${expandedJob() === job.id ? "rotate-90" : ""}`} />
Runs
</button>
<Show when={props.canManage}>
<button
class="btn btn-ghost btn-sm"
onClick={() => runNow(job)}
disabled={busy() === `run-${job.id}`}
title="Trigger a manual run now"
>
<IconPlay class="w-4 h-4" />
Run
</button>
<button
class="btn btn-ghost btn-sm"
onClick={() => deleteJob(job)}
disabled={busy() === `del-${job.id}`}
>
<IconTrash class="w-4 h-4" />
</button>
</Show>
</td>
</tr>
<Show when={expandedJob() === job.id}>
<tr>
<td colspan={7} style="padding:0">
<div class="p-4" style="background:var(--bg-subtle)">
<div class="text-xs text-text-2 mb-2 font-medium uppercase tracking-wide">
Run history — last {(runs()[job.id] ?? []).length} of 200 kept
</div>
<Show
when={(runs()[job.id] ?? []).length > 0}
fallback={<p class="text-text-2 text-sm">No runs yet.</p>}
>
<table class="table">
<thead>
<tr>
<th>Status</th>
<th>Trigger</th>
<th class="hidden md:table-cell">HTTP</th>
<th class="hidden md:table-cell">Duration</th>
<th>Started</th>
<th>Error</th>
</tr>
</thead>
<tbody>
<For each={runs()[job.id] ?? []}>
{(r) => (
<tr>
<td>
<Badge variant={statusVariant(r.status)}>{r.status}</Badge>
</td>
<td class="text-text-2">{r.triggered_by}</td>
<td class="hidden md:table-cell text-text-2">{r.status_code ?? "—"}</td>
<td class="hidden md:table-cell text-text-2">
{r.duration_ms != null ? `${r.duration_ms} ms` : "—"}
</td>
<td class="text-text-2">{fmtTime(r.started_at)}</td>
<td class="text-text-2" style="max-width:16rem;overflow:hidden;text-overflow:ellipsis">
{r.error || "—"}
</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
</div>
</td>
</tr>
</Show>
</>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
</Show>
<Show when={tab() === "events"}>
<div class="card card-flush">
<div class="card-header">
<div class="flex-1">
<div class="card-header-title">
Live events{" "}
<Badge variant={live() ? "success" : "neutral"}>{live() ? "live" : "offline"}</Badge>
</div>
<div class="card-header-description">
Domain events as they happen — document and object mutations, new issues, deploy
markers, job runs. Webhooks subscribed to the same types fire in parallel.
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" onClick={() => setPaused(!paused())}>
{paused() ? "Resume" : "Pause"}
</button>
<button class="btn btn-secondary btn-sm" onClick={() => setEvents([])}>
Clear
</button>
</div>
</div>
<Show when={eventTypes().length > 1}>
<div class="flex gap-2 p-4 pb-0 flex-wrap">
<button
class={`badge cursor-pointer border-0 ${typeFilter() === "" ? "badge-primary" : ""}`}
onClick={() => setTypeFilter("")}
>
all
</button>
<For each={eventTypes()}>
{(t) => (
<button
class={`badge cursor-pointer border-0 ${typeFilter() === t ? "badge-primary" : ""}`}
onClick={() => setTypeFilter(typeFilter() === t ? "" : t)}
>
{t}
</button>
)}
</For>
</div>
</Show>
<Show
when={filteredEvents().length > 0}
fallback={
<p class="text-text-2 text-sm p-4">
{live()
? "Listening — create a document, upload a file, or run a job and it lands here."
: "Connecting to the event stream…"}
</p>
}
>
<div class="table-container">
<table class="table">
<tbody>
<For each={filteredEvents()}>
{(ev) => (
<tr>
<td style="width:11rem">
<Badge variant={eventVariant(ev.type)}>{ev.type}</Badge>
</td>
<td class="text-text-2 text-xs" style="width:11rem">
{fmtTime(ev.occurred_at)}
</td>
<td>
<code
class="text-xs block"
style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:36rem"
title={JSON.stringify(ev.data, null, 2)}
>
{JSON.stringify(ev.data)}
</code>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
</Show>
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New schedule" size="md">
<form onSubmit={createJob} class="space-y-4">
<Input
label="Name"
value={jobForm().name}
onInput={(e) => setJobForm((c) => ({ ...c, name: e.currentTarget.value }))}
placeholder="nightly-rollup"
required
/>
<div>
<Input
label="Schedule"
value={jobForm().schedule}
onInput={(e) => setJobForm((c) => ({ ...c, schedule: e.currentTarget.value }))}
placeholder="*/15 * * * * or @every 1h"
required
/>
<p class="label-hint">
Standard 5-field cron, or a descriptor: <code>@hourly</code>, <code>@daily</code>,{" "}
<code>@every 30m</code>.
</p>
</div>
<div>
<Input
label="Target URL"
value={jobForm().url}
onInput={(e) => setJobForm((c) => ({ ...c, url: e.currentTarget.value }))}
placeholder="https://api.example.com/jobs/rollup"
required
/>
<p class="label-hint">
HTTPS for public endpoints; plain HTTP allowed for private/self-hosted targets.
</p>
</div>
<Textarea
label="Payload (optional JSON)"
class="font-mono text-xs"
rows={3}
value={jobForm().payload}
onInput={(e) => setJobForm((c) => ({ ...c, payload: e.currentTarget.value }))}
placeholder='{"task": "rollup", "window": "24h"}'
/>
<Input
label="Signing secret (optional)"
value={jobForm().secret}
onInput={(e) => setJobForm((c) => ({ ...c, secret: e.currentTarget.value }))}
placeholder="generated if empty"
/>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jobForm().enabled}
onChange={(e) => setJobForm((c) => ({ ...c, enabled: e.currentTarget.checked }))}
/>
Enabled — start running on schedule immediately
</label>
<div class="flex gap-2 justify-end">
<button type="button" class="btn btn-secondary" onClick={() => setShowCreate(false)}>
Cancel
</button>
<button type="submit" class="btn btn-primary" disabled={busy() === "create-job"}>
<IconZap class="w-4 h-4" />
Create schedule
</button>
</div>
</form>
</Modal>
</div>
);
}
+80 -41
View File
@@ -18,6 +18,12 @@ interface LoginPageProps {
name: string;
authMessage: string;
authPending: boolean;
/** False when the instance closed public sign-up (and bootstrap is done). */
canSignUp: boolean;
/** True while no user exists — the first sign-up becomes instance admin. */
bootstrapRequired: boolean;
/** Social providers the instance offers (empty on self-hosted). */
enabledProviders: string[];
onModeChange: (mode: AuthMode) => void;
onEmailChange: (value: string) => void;
onPasswordChange: (value: string) => void;
@@ -35,7 +41,9 @@ const socialProviders: { id: Provider; label: string; logo: (p: { class?: string
];
export function LoginPage(props: LoginPageProps) {
const isSignIn = () => props.mode === "sign-in";
const isSignIn = () => !props.bootstrapRequired && props.mode === "sign-in";
const providers = () =>
socialProviders.filter((p) => props.enabledProviders.includes(p.id));
return (
<div class="auth-wrap">
@@ -70,41 +78,54 @@ export function LoginPage(props: LoginPageProps) {
<span class="font-semibold tracking-tight" style="color: var(--text-1)">Primora</span>
</div>
<h1 style="font-size: 1.25rem; letter-spacing: -0.02em;">
{isSignIn() ? "Sign in" : "Create your account"}
{isSignIn() ? "Sign in" : props.bootstrapRequired ? "Create the admin account" : "Create your account"}
</h1>
<p class="mt-1" style="font-size: 0.8125rem; color: var(--text-3)">
{isSignIn()
? "Access your workspace"
: "One account for the whole workspace"}
: props.bootstrapRequired
? "The first account becomes the instance administrator"
: "One account for the whole workspace"}
</p>
</div>
{/* mode toggle */}
<div class="seg mb-6" role="tablist" aria-label="Authentication mode">
<button
type="button"
role="tab"
aria-selected={isSignIn()}
class={`seg-item ${isSignIn() ? "active" : ""}`}
onClick={() => props.onModeChange("sign-in")}
>
Sign in
</button>
<button
type="button"
role="tab"
aria-selected={!isSignIn()}
class={`seg-item ${!isSignIn() ? "active" : ""}`}
onClick={() => props.onModeChange("sign-up")}
>
Sign up
</button>
</div>
{/* mode toggle — hidden during bootstrap; sign-up tab hidden when closed */}
<Show when={!props.bootstrapRequired}>
<div class="seg mb-6" role="tablist" aria-label="Authentication mode">
<button
type="button"
role="tab"
aria-selected={isSignIn()}
class={`seg-item ${isSignIn() ? "active" : ""}`}
onClick={() => props.onModeChange("sign-in")}
>
Sign in
</button>
<Show when={props.canSignUp}>
<button
type="button"
role="tab"
aria-selected={!isSignIn()}
class={`seg-item ${!isSignIn() ? "active" : ""}`}
onClick={() => props.onModeChange("sign-up")}
>
Sign up
</button>
</Show>
</div>
</Show>
<Show when={props.bootstrapRequired}>
<div class="message message-info mb-6">
This instance has no users yet. Create the first account — it becomes the administrator.
</div>
</Show>
<form onSubmit={props.onSubmit} class="space-y-4">
<Show when={!isSignIn()}>
<Input
label="Name"
id="name"
name="name"
type="text"
placeholder="Ada Lovelace"
value={props.name}
@@ -115,15 +136,19 @@ export function LoginPage(props: LoginPageProps) {
</Show>
<Input
label="Email"
id="email"
name="email"
type="email"
placeholder="[email protected]"
value={props.email}
onInput={(e) => props.onEmailChange(e.currentTarget.value)}
autocomplete="email"
autocomplete="username"
required
/>
<Input
label="Password"
id="password"
name="password"
type="password"
placeholder="••••••••"
value={props.password}
@@ -143,29 +168,43 @@ export function LoginPage(props: LoginPageProps) {
>
<Show
when={props.authPending}
fallback={isSignIn() ? "Sign in" : "Create account"}
fallback={
isSignIn()
? "Sign in"
: props.bootstrapRequired
? "Create admin account"
: "Create account"
}
>
<span class="spinner" /> Working…
</Show>
</button>
</form>
<div class="divider my-6">or continue with</div>
<Show when={!props.canSignUp && !props.bootstrapRequired}>
<p class="mt-4 text-center" style="font-size: 0.8125rem; color: var(--text-3)">
Public sign-up is closed on this instance.
</p>
</Show>
<div class="grid grid-cols-4 gap-2">
{socialProviders.map((p) => (
<button
type="button"
class="btn btn-secondary btn-icon w-full"
onClick={() => props.onSocial(p.id)}
disabled={props.authPending}
title={`Continue with ${p.label}`}
aria-label={`Continue with ${p.label}`}
>
<p.logo class="w-4 h-4" />
</button>
))}
</div>
<Show when={providers().length > 0}>
<div class="divider my-6">or continue with</div>
<div class={`grid gap-2 ${providers().length === 1 ? "grid-cols-1" : providers().length === 2 ? "grid-cols-2" : "grid-cols-4"}`}>
{providers().map((p) => (
<button
type="button"
class="btn btn-secondary btn-icon w-full"
onClick={() => props.onSocial(p.id)}
disabled={props.authPending}
title={`Continue with ${p.label}`}
aria-label={`Continue with ${p.label}`}
>
<p.logo class="w-4 h-4" />
</button>
))}
</div>
</Show>
<Show when={props.onTryDemo}>
<div class="divider my-6" />
+40 -2
View File
@@ -11,16 +11,22 @@ interface ProjectInput {
description: string;
}
interface ProjectEditInput extends ProjectInput {
retentionEventsDays: string;
retentionAuditDays: string;
retentionWebhookDays: string;
}
interface ProjectsPageProps {
projects: ProjectSummary[];
selectedProjectID?: string;
projectInput: ProjectInput;
projectEditInput: ProjectInput;
projectEditInput: ProjectEditInput;
projectMessage: string;
projectPending: boolean;
canUpdateProject: boolean;
onProjectInputChange: (field: keyof ProjectInput, value: string) => void;
onProjectEditInputChange: (field: keyof ProjectInput, value: string) => void;
onProjectEditInputChange: (field: keyof ProjectEditInput, value: string) => void;
onCreateProject: (event: SubmitEvent) => void;
onUpdateProject: (event: SubmitEvent) => void;
onDeleteProject: () => void;
@@ -252,6 +258,38 @@ export function ProjectsPage(props: ProjectsPageProps) {
value={props.projectEditInput.description}
onInput={(e) => props.onProjectEditInputChange("description", e.currentTarget.value)}
/>
<div>
<span class="label">Data retention (days)</span>
<p class="label-hint mb-2">
Rows older than this are swept hourly. 0 disables a category. Max 3650.
</p>
<div class="grid gap-3 sm:grid-cols-3">
<Input
label="Telemetry events"
type="number"
min={0}
max={3650}
value={props.projectEditInput.retentionEventsDays}
onInput={(e) => props.onProjectEditInputChange("retentionEventsDays", e.currentTarget.value)}
/>
<Input
label="Audit log"
type="number"
min={0}
max={3650}
value={props.projectEditInput.retentionAuditDays}
onInput={(e) => props.onProjectEditInputChange("retentionAuditDays", e.currentTarget.value)}
/>
<Input
label="Webhook deliveries"
type="number"
min={0}
max={3650}
value={props.projectEditInput.retentionWebhookDays}
onInput={(e) => props.onProjectEditInputChange("retentionWebhookDays", e.currentTarget.value)}
/>
</div>
</div>
<ModalFooter>
<button type="button" class="btn btn-ghost" onClick={() => setSettingsOpen(false)}>
Cancel
+223 -3
View File
@@ -1,5 +1,5 @@
import { For, Show, createSignal } from "solid-js";
import type { ApiKey } from "@primora/api-client";
import type { ApiKey, InstanceSetting } from "@primora/api-client";
import { Badge } from "../components/Badge";
import { Modal, ModalFooter } from "../components/Modal";
import { Input } from "../components/Input";
@@ -17,9 +17,23 @@ interface OrgInput {
slug: string;
}
const API_KEY_SCOPES: Array<{ value: string; label: string; hint: string }> = [
{ value: "ingest", label: "ingest", hint: "telemetry ingest only" },
{ value: "read", label: "read", hint: "read endpoints" },
{ value: "write", label: "write", hint: "read + ingest + mutations" },
{ value: "admin", label: "admin", hint: "full access" },
];
interface SettingsPageProps {
isPlatformAdmin: boolean;
instanceSettings?: InstanceSetting[];
instancePending: boolean;
instanceMessage: string;
onSaveInstanceSetting: (key: string, value: unknown) => void;
onResetInstanceSetting: (key: string) => void;
apiKeys?: ApiKey[];
apiKeyName: string;
apiKeyScopes: string[];
apiKeySecret: string;
apiKeyMessage: string;
apiKeyPending: boolean;
@@ -30,6 +44,7 @@ interface SettingsPageProps {
workspacePending: boolean;
hasActiveOrganization: boolean;
onApiKeyNameChange: (name: string) => void;
onApiKeyScopeToggle: (scope: string) => void;
onCreateApiKey: () => void;
onDeleteApiKey: (id: string) => void;
onOrganizationInputChange: (field: keyof OrgInput, value: string) => void;
@@ -41,8 +56,27 @@ interface SettingsPageProps {
formatDate: (value?: string | null) => string;
}
const INSTANCE_GROUPS: { prefix: string; title: string; hint: string }[] = [
{ prefix: "auth.", title: "Authentication", hint: "Who can create accounts on this instance." },
{ prefix: "mail.", title: "Mail", hint: "Outgoing email — Resend or SMTP. In-app values override .env." },
{ prefix: "ratelimit.", title: "Rate limits", hint: "Requests per minute, enforced by the API." },
];
const SETTING_LABELS: Record<string, string> = {
"auth.signup_enabled": "Public sign-up",
"mail.from": "From address",
"mail.resend_api_key": "Resend API key",
"mail.smtp_host": "SMTP host",
"mail.smtp_port": "SMTP port",
"mail.smtp_user": "SMTP user",
"mail.smtp_password": "SMTP password",
"mail.smtp_secure": "SMTP TLS",
"ratelimit.user_per_minute": "User requests / minute",
"ratelimit.api_key_per_minute": "API key requests / minute",
};
export function SettingsPage(props: SettingsPageProps) {
const [tab, setTab] = createSignal<"api-keys" | "organization">("api-keys");
const [tab, setTab] = createSignal<"api-keys" | "organization" | "instance">("api-keys");
const [copied, setCopied] = createSignal(false);
const [createOrgOpen, setCreateOrgOpen] = createSignal(false);
const [confirmDeleteOrg, setConfirmDeleteOrg] = createSignal(false);
@@ -85,6 +119,14 @@ export function SettingsPage(props: SettingsPageProps) {
>
Organization
</button>
<Show when={props.isPlatformAdmin}>
<button
class={`tab ${tab() === "instance" ? "active" : ""}`}
onClick={() => setTab("instance")}
>
Instance
</button>
</Show>
</div>
{/* ---------------- API keys ---------------- */}
@@ -112,12 +154,34 @@ export function SettingsPage(props: SettingsPageProps) {
<button
class="btn btn-primary"
onClick={props.onCreateApiKey}
disabled={props.apiKeyPending || !props.apiKeyName.trim()}
disabled={props.apiKeyPending || !props.apiKeyName.trim() || props.apiKeyScopes.length === 0}
>
<IconPlus class="w-4 h-4" />
Create key
</button>
</div>
<div class="mt-4">
<span class="label">Scopes</span>
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
<For each={API_KEY_SCOPES}>
{(scope) => (
<label class="flex items-center gap-2 text-sm text-text-2">
<input
type="checkbox"
class="accent-[var(--accent)]"
checked={props.apiKeyScopes.includes(scope.value)}
disabled={props.apiKeyScopes.length === 1 && props.apiKeyScopes.includes(scope.value)}
onChange={() => props.onApiKeyScopeToggle(scope.value)}
/>
<span>
<code>{scope.label}</code>
<span class="text-text-3"> — {scope.hint}</span>
</span>
</label>
)}
</For>
</div>
</div>
</div>
<div class="card card-flush">
@@ -141,6 +205,7 @@ export function SettingsPage(props: SettingsPageProps) {
<tr>
<th>Name</th>
<th>Prefix</th>
<th>Scopes</th>
<th>Last used</th>
<th>Status</th>
<th style="width:1%" />
@@ -154,6 +219,13 @@ export function SettingsPage(props: SettingsPageProps) {
<td>
<code>{key.prefix}…</code>
</td>
<td>
<div class="flex flex-wrap gap-1">
<For each={key.scopes && key.scopes.length > 0 ? key.scopes : ["admin"]}>
{(scope) => <Badge variant="neutral">{scope}</Badge>}
</For>
</div>
</td>
<td class="text-text-3 text-xs">
{key.last_used_at ? props.formatDate(key.last_used_at) : "Never"}
</td>
@@ -283,6 +355,53 @@ export function SettingsPage(props: SettingsPageProps) {
</Show>
</Show>
{/* ---------------- Instance (platform admin) ---------------- */}
<Show when={tab() === "instance" && props.isPlatformAdmin}>
<Show when={props.instanceMessage}>
<div class="message message-neutral">{props.instanceMessage}</div>
</Show>
<p class="text-xs text-text-3 mb-4">
In-app values override the matching environment variables. Secrets are stored encrypted
and never shown back.
</p>
<For each={INSTANCE_GROUPS}>
{(group) => {
const rows = () => (props.instanceSettings ?? []).filter((s) => s.key.startsWith(group.prefix));
return (
<Show when={rows().length > 0}>
<div class="card card-flush mb-4">
<div class="card-header">
<span class="card-header-title">{group.title}</span>
<span class="card-header-description">{group.hint}</span>
</div>
<div class="divide-y" style="border-color:var(--border)">
<For each={rows()}>
{(setting) => (
<SettingRow
setting={setting}
pending={props.instancePending}
onSave={(value) => props.onSaveInstanceSetting(setting.key, value)}
onReset={() => props.onResetInstanceSetting(setting.key)}
/>
)}
</For>
</div>
</div>
</Show>
);
}}
</For>
<Show when={!props.instanceSettings}>
<div class="card empty-state">
<div class="empty-state-icon"><IconKey class="w-5 h-5" /></div>
<p class="empty-state-title">Loading instance settings</p>
</div>
</Show>
</Show>
{/* API key secret modal — shown while App holds a fresh secret */}
<Show when={props.apiKeySecret}>
<Modal
@@ -373,3 +492,104 @@ export function SettingsPage(props: SettingsPageProps) {
</div>
);
}
/** Values arrive as JSON strings ("true", "240") — decode per declared type. */
function settingText(setting: InstanceSetting): string {
if (setting.value === undefined || setting.value === null) return "";
return String(setting.value);
}
const SOURCE_BADGE: Record<string, "success" | "primary" | "neutral"> = {
app: "success",
env: "primary",
default: "neutral",
};
function SettingRow(props: {
setting: InstanceSetting;
pending: boolean;
onSave: (value: unknown) => void;
onReset: () => void;
}) {
const s = () => props.setting;
const [draft, setDraft] = createSignal(settingText(props.setting));
const dirty = () => {
if (s().type === "bool") return false; // toggles save instantly
return draft() !== settingText(s());
};
const saveDraft = () => {
if (s().type === "int") {
const n = Number(draft());
if (!Number.isInteger(n) || n < 0) return;
props.onSave(n);
return;
}
props.onSave(draft());
};
return (
<div class="flex items-center gap-3 px-4 py-3 flex-wrap">
<div class="flex-1" style="min-width:12rem">
<div class="text-sm font-medium text-text-1">
{SETTING_LABELS[s().key] ?? s().key}
</div>
<code class="text-xs text-text-3">{s().key}</code>
</div>
<Badge variant={SOURCE_BADGE[s().source] ?? "neutral"}>
{s().is_set ? "in-app" : s().source}
</Badge>
<Show
when={s().type === "bool"}
fallback={
<div class="flex items-center gap-2">
<input
class="input input-sm"
style="width:12rem"
type={s().secret ? "password" : s().type === "int" ? "number" : "text"}
min={s().type === "int" ? 0 : undefined}
placeholder={s().secret ? (s().is_set ? "•••••••• (stored)" : "not set") : ""}
value={draft()}
onInput={(e) => setDraft(e.currentTarget.value)}
disabled={props.pending}
aria-label={SETTING_LABELS[s().key] ?? s().key}
/>
<button
class="btn btn-secondary btn-sm"
onClick={saveDraft}
disabled={props.pending || !dirty()}
>
Save
</button>
</div>
}
>
<label class="flex items-center gap-2 text-sm text-text-2">
<input
type="checkbox"
class="accent-[var(--accent)]"
checked={settingText(s()) === "true"}
disabled={props.pending}
onChange={(e) => props.onSave(e.currentTarget.checked)}
aria-label={SETTING_LABELS[s().key] ?? s().key}
/>
{settingText(s()) === "true" ? "Enabled" : "Disabled"}
</label>
</Show>
<Show when={s().is_set}>
<button
class="btn btn-ghost btn-sm"
onClick={props.onReset}
disabled={props.pending}
title="Remove the in-app override — env/default applies again"
>
Reset
</button>
</Show>
</div>
);
}
+1
View File
@@ -7,6 +7,7 @@ export { CollectionsPage } from "./CollectionsPage";
export { DatabasesPage } from "./DatabasesPage";
export { TelemetryPage } from "./TelemetryPage";
export { IntegrationsPage } from "./IntegrationsPage";
export { AutomationPage } from "./AutomationPage";
export { SettingsPage } from "./SettingsPage";
export { AuditPage } from "./AuditPage";
export { AuthPage, type AuthUser } from "./AuthPage";
+23
View File
@@ -0,0 +1,23 @@
# @primora/mcp
Primora MCP stdio server - exposes the platform as `primora_*` tools to any
MCP client (Claude Desktop, Cursor, etc.).
## Build
```bash
npm run build --workspace @primora/mcp # → dist/server.js
```
## Configure
Point your MCP client at `node dist/server.js`. The server shares the CLI's
config file - `primora login` once and both tools work. Env vars override:
`PRIMORA_BASE_URL`, `PRIMORA_API_KEY`, `PRIMORA_ORG`, `PRIMORA_PROJECT`.
## Tools
30+ `primora_*` tools covering organizations, projects, buckets, objects,
collections, API keys, integrations, webhooks, scheduled jobs, telemetry,
and audit: `primora_whoami`, `primora_list_projects`,
`primora_upload_object`, `primora_query_audit`, `primora_run_job`, …
+149 -1
View File
@@ -3,6 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { z } from "zod";
import {
AutomationService,
CreateBucketRequest,
IntegrationsService,
OpenAPI,
@@ -574,7 +575,20 @@ server.registerTool(
const webhookRef = z.string().describe("Webhook id (see primora_list_webhooks)");
const webhookEvents = z
.array(z.enum(["issue.created", "deploy.marker", "webhook.test"]))
.array(
z.enum([
"issue.created",
"deploy.marker",
"webhook.test",
"job.run",
"document.created",
"document.updated",
"document.deleted",
"object.created",
"object.updated",
"object.deleted",
]),
)
.describe("Event filter — empty means every event");
server.registerTool(
@@ -732,4 +746,138 @@ server.registerTool(
},
);
/* ---------------------------------------------------------- */
/* automation — scheduled jobs */
/* ---------------------------------------------------------- */
const jobRef = z.string().describe("Scheduled job id (see primora_list_jobs)");
server.registerTool(
"primora_list_jobs",
{
description: "List scheduled jobs for a project — cron schedule, target URL, last/next run",
inputSchema: { projectId: z.string().optional() },
},
async ({ projectId: p }) => {
try {
return ok(await AutomationService.listScheduledJobs({ projectId: projectId(p) }));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_create_job",
{
description:
"Create a scheduled job — cron expression or @descriptor, signed POST to url. Omit secret to generate one (returned once)",
inputSchema: {
name: z.string(),
schedule: z
.string()
.describe('Cron (5-field, e.g. "*/15 * * * *") or descriptor: @hourly, @daily, @every 30m'),
url: z.string().describe("HTTPS endpoint (http allowed only for private/self-hosted targets)"),
secret: z.string().optional(),
payload: z.record(z.unknown()).optional().describe("Static JSON payload merged into every delivery"),
enabled: z.boolean().optional(),
projectId: z.string().optional(),
},
},
async ({ name, schedule, url, secret, payload, enabled, projectId: p }) => {
try {
return ok(
await AutomationService.createScheduledJob({
projectId: projectId(p),
requestBody: { name, schedule, url, secret, payload, enabled },
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_update_job",
{
description: "Update a scheduled job — name, schedule, url, payload, enabled flag, or rotate the secret",
inputSchema: {
jobId: jobRef,
name: z.string().optional(),
schedule: z.string().optional(),
url: z.string().optional(),
secret: z.string().optional(),
payload: z.record(z.unknown()).optional(),
enabled: z.boolean().optional(),
projectId: z.string().optional(),
},
},
async ({ jobId, name, schedule, url, secret, payload, enabled, projectId: p }) => {
try {
return ok(
await AutomationService.updateScheduledJob({
projectId: projectId(p),
jobId,
requestBody: { name, schedule, url, secret, payload, enabled },
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_delete_job",
{
description: "Delete a scheduled job and stop future runs",
inputSchema: { jobId: jobRef, projectId: z.string().optional() },
},
async ({ jobId, projectId: p }) => {
try {
await AutomationService.deleteScheduledJob({ projectId: projectId(p), jobId });
return ok({ deleted: jobId });
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_list_job_runs",
{
description: "Run history for a scheduled job — status, HTTP code, duration, errors",
inputSchema: {
jobId: jobRef,
limit: z.number().int().optional(),
projectId: z.string().optional(),
},
},
async ({ jobId, limit, projectId: p }) => {
try {
return ok(
await AutomationService.listScheduledJobRuns({ projectId: projectId(p), jobId, limit }),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_run_job",
{
description: "Trigger a manual run of a scheduled job now",
inputSchema: { jobId: jobRef, projectId: z.string().optional() },
},
async ({ jobId, projectId: p }) => {
try {
return ok(await AutomationService.runScheduledJob({ projectId: projectId(p), jobId }));
} catch (e) {
return fail(e);
}
},
);
await server.connect(new StdioServerTransport());
+28
View File
@@ -0,0 +1,28 @@
# Development overlay — services that must never ship to production.
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
services:
mailpit:
image: axllent/mailpit:v1.27.9
ports:
- "${MAILPIT_HTTP_PORT}:8025"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8025/ >/dev/null 2>&1 || exit 1"]
interval: 15s
timeout: 5s
retries: 10
restart: unless-stopped
auth:
environment:
SMTP_HOST: mailpit
SMTP_PORT: 1025
depends_on:
mailpit:
condition: service_started
nginx:
volumes:
- ./infra/nginx/mailpit.conf:/etc/nginx/primora-extra/mailpit.conf:ro
depends_on:
mailpit:
condition: service_started
+41 -18
View File
@@ -14,21 +14,22 @@ services:
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
dragonfly:
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
image: docker.dragonflydb.io/dragonflydb/dragonfly:v2.0.0
command: ["--logtostderr", "--proactor_threads=4"]
ports:
- "${DRAGONFLY_PORT:-6379}:6379"
restart: unless-stopped
mailpit:
image: axllent/mailpit:latest
ports:
- "${MAILPIT_HTTP_PORT}:8025"
healthcheck:
test: ["CMD-SHELL", "redis-cli -p 6379 ping | grep -q PONG"]
interval: 10s
timeout: 3s
retries: 10
restart: unless-stopped
backend:
image: ghcr.io/dvorinka/primora-backend:latest
build:
context: .
dockerfile: apps/backend/Dockerfile
@@ -47,9 +48,16 @@ services:
condition: service_started
volumes:
- backend_storage:/data/storage
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health/liveness || exit 1"]
interval: 15s
timeout: 5s
retries: 10
start_period: 20s
restart: unless-stopped
auth:
image: ghcr.io/dvorinka/primora-auth:latest
build:
context: .
dockerfile: apps/auth/Dockerfile
@@ -58,21 +66,24 @@ services:
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
DRAGONFLY_URL: redis://dragonfly:6379/0
BETTER_AUTH_URL: http://localhost/auth
AUTH_BASE_URL: http://localhost/auth
VITE_APP_URL: http://localhost
SMTP_HOST: mailpit
SMTP_PORT: 1025
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost/auth}
AUTH_BASE_URL: ${AUTH_BASE_URL:-http://localhost/auth}
VITE_APP_URL: ${VITE_APP_URL:-http://localhost}
depends_on:
postgres:
condition: service_healthy
dragonfly:
condition: service_started
mailpit:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1 || exit 1"]
interval: 15s
timeout: 5s
retries: 10
start_period: 20s
restart: unless-stopped
frontend:
image: ghcr.io/dvorinka/primora-frontend:latest
build:
context: .
dockerfile: apps/frontend/Dockerfile
@@ -88,19 +99,31 @@ services:
condition: service_started
auth:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/ >/dev/null 2>&1 || exit 1"]
interval: 15s
timeout: 5s
retries: 10
restart: unless-stopped
nginx:
image: nginx:1.27-alpine
depends_on:
- backend
- auth
- frontend
- mailpit
backend:
condition: service_started
auth:
condition: service_started
frontend:
condition: service_started
ports:
- "${NGINX_PORT}:80"
volumes:
- ./infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/nginx-health >/dev/null 2>&1 || exit 1"]
interval: 15s
timeout: 5s
retries: 10
restart: unless-stopped
volumes:
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+21 -10
View File
@@ -9,6 +9,16 @@ server {
# startup-time resolution forever (stale upstream → 502).
resolver 127.0.0.11 valid=10s ipv6=off;
# Baseline security headers on every response, API included.
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), usb=()" always;
location = /nginx-health {
return 204;
}
location /api/ {
set $upstream http://backend:8080;
proxy_pass $upstream;
@@ -40,18 +50,19 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /mailpit/ {
set $upstream http://mailpit:8025;
rewrite ^/mailpit/?(.*)$ /$1 break;
proxy_pass $upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Dev-only location blocks (mailpit) are dropped in here by
# docker-compose.dev.yml. A glob with no matches is legal, so production
# simply includes nothing.
include /etc/nginx/primora-extra/*.conf;
location / {
# Any add_header inside a location replaces ALL inherited add_headers —
# so the baseline four must be repeated here next to the SPA-only CSP.
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), usb=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
set $upstream http://frontend:80;
proxy_pass $upstream;
proxy_http_version 1.1;
+12
View File
@@ -0,0 +1,12 @@
# Dev-only: mounted into /etc/nginx/primora-extra/ by docker-compose.dev.yml.
# Included inside the default server block — do not add a server{} here.
location /mailpit/ {
set $upstream http://mailpit:8025;
rewrite ^/mailpit/?(.*)$ /$1 break;
proxy_pass $upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Executable
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Primora one-line installer.
#
# curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash
#
# Installs into ./primora (override with PRIMORA_DIR). Non-interactive:
# DOMAIN=example.com NGINX_PORT=8080 bash install.sh
#
# Pulls prebuilt GHCR images when available; otherwise downloads the source
# tarball and builds locally. Either way the result is the same compose stack.
set -euo pipefail
REPO="Dvorinka/Primora"
REF="${PRIMORA_REF:-master}"
DIR="${PRIMORA_DIR:-primora}"
DOMAIN="${DOMAIN:-localhost}"
NGINX_PORT="${NGINX_PORT:-80}"
info() { echo "==> $*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
# '|' delimiter for sed: base64 output can contain '/', '+', '='.
SED=(sed -i)
[[ "$OSTYPE" == darwin* ]] && SED=(sed -i '')
command -v docker >/dev/null 2>&1 || die "docker is not installed"
docker compose version >/dev/null 2>&1 || die "docker compose plugin is not installed"
command -v curl >/dev/null 2>&1 || die "curl is not installed"
mkdir -p "$DIR/infra/nginx"
cd "$DIR"
# The compose stack needs exactly these three files from the repo.
info "Fetching compose files ($REF)"
for f in docker-compose.yml .env.example infra/nginx/default.conf; do
curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/$f" -o "$f"
done
if [ ! -f .env ]; then
info "Generating .env with random secrets"
cp .env.example .env
JWT_SECRET=$(openssl rand -base64 32)
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 16)
"${SED[@]}" "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$DB_PASSWORD|" .env
"${SED[@]}" "s|postgres://primora:primora@|postgres://primora:$DB_PASSWORD@|" .env
"${SED[@]}" "s|^JWT_SECRET=.*|JWT_SECRET=$JWT_SECRET|" .env
"${SED[@]}" "s|^BETTER_AUTH_SECRET=.*|BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET|" .env
"${SED[@]}" "s|^PRIMORA_ENCRYPTION_KEY=.*|PRIMORA_ENCRYPTION_KEY=$ENCRYPTION_KEY|" .env
"${SED[@]}" "s|^NGINX_PORT=.*|NGINX_PORT=$NGINX_PORT|" .env
if [ "$DOMAIN" != "localhost" ]; then
"${SED[@]}" "s|http://localhost|http://$DOMAIN|g" .env
"${SED[@]}" "s|^COOKIE_DOMAIN=.*|COOKIE_DOMAIN=$DOMAIN|" .env
fi
else
info "Existing .env kept"
fi
# Compose v5 skips `pull` for services that declare build:, so fetch the
# prebuilt images directly. --no-build keeps `up` from rebuilding them.
GHCR_OWNER=$(echo "$REPO" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]')
if docker pull "ghcr.io/$GHCR_OWNER/primora-backend:latest" \
&& docker pull "ghcr.io/$GHCR_OWNER/primora-auth:latest" \
&& docker pull "ghcr.io/$GHCR_OWNER/primora-frontend:latest"; then
info "Starting prebuilt images"
# The source tree isn't downloaded on this path — strip build: blocks so a
# later `compose build`/`up --build` can't fail on missing apps/*/Dockerfile.
"${SED[@]}" '/^[[:space:]]*build:/,/^[[:space:]]*dockerfile:/d' docker-compose.yml
docker compose up -d --no-build
else
# GHCR packages are private until first published release — build from source.
info "Prebuilt images unavailable; building from source"
curl -fsSL "https://codeload.github.com/$REPO/tar.gz/refs/heads/$REF" | tar xz --strip-components=1
docker compose up -d --build
fi
DISPLAY_URL="http://$DOMAIN"
[ "$NGINX_PORT" != "80" ] && DISPLAY_URL="$DISPLAY_URL:$NGINX_PORT"
info "Primora is starting"
echo " Dashboard: $DISPLAY_URL"
echo " Health: $DISPLAY_URL/api/v1/health/liveness"
echo " Logs: cd $DIR && docker compose logs -f"
echo " Stop: cd $DIR && docker compose down"
+2
View File
@@ -0,0 +1,2 @@
.vercel
.env*
Binary file not shown.
Binary file not shown.
+641
View File
@@ -0,0 +1,641 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Primora — Self-hosted backend platform</title>
<meta name="description" content="Primora is a self-hosted backend platform: auth, Postgres-backed APIs, S3-style object storage, JSON collections, scoped API keys, and a full audit log on your own hardware.">
<meta property="og:title" content="Primora — Self-hosted backend platform">
<meta property="og:description" content="Auth, Postgres-backed APIs, object storage, and audit logs on your own hardware. No hosted tier.">
<meta property="og:type" content="website">
<meta property="og:image" content="screenshots/overview.png">
<link rel="icon" type="image/svg+xml" href="primora.svg">
<style>
@font-face {
font-family: "Geist";
src: url("fonts/geist-sans.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: "Geist Mono";
src: url("fonts/geist-mono.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
:root {
--bg: #0a0a0b;
--bg-panel: #0f0f11;
--surface: #141416;
--surface-2: #1b1b1f;
--border: #222227;
--border-strong: #303036;
--text-1: #f4f4f5;
--text-2: #a2a2ab;
--text-3: #63636d;
--accent: #f4693a;
--accent-hover: #ff7c4e;
--accent-fg: #1c0d05;
--accent-muted: rgba(244, 105, 58, 0.12);
--accent-border: rgba(244, 105, 58, 0.35);
--success: #4cc38a;
--font-sans: "Geist", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: "Geist Mono", "JetBrains Mono", ui-monospace, "SF Mono", monospace;
--radius: 8px;
--radius-lg: 12px;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: var(--font-sans);
font-size: 0.9375rem;
line-height: 1.6;
letter-spacing: -0.006em;
background: var(--bg);
color: var(--text-1);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
::selection { background: var(--accent-border); color: var(--text-1); }
a { color: inherit; text-decoration: none; }
code, pre, .mono { font-family: var(--font-mono); }
.wrap { max-width: 72rem; margin: 0 auto; padding: 0 1.5rem; }
/* ------------------------------ nav ------------------------------ */
.nav {
position: fixed; inset: 0 0 auto 0; z-index: 50;
border-bottom: 1px solid var(--border);
background: rgba(10, 10, 11, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.nav-inner {
display: flex; align-items: center; justify-content: space-between;
height: 3.5rem;
}
.nav-brand { display: flex; align-items: center; gap: 0.625rem; font-weight: 600; letter-spacing: -0.02em; }
.nav-brand img { width: 1.375rem; height: 1.375rem; border-radius: 5px; }
.nav-links { display: flex; align-items: center; gap: 1.75rem; font-size: 0.8125rem; color: var(--text-2); }
.nav-links a:hover { color: var(--text-1); }
.nav-cta {
display: inline-flex; align-items: center; gap: 0.5rem;
font-size: 0.8125rem; font-weight: 500;
color: var(--text-1);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0.375rem 0.75rem;
transition: border-color 0.15s, background 0.15s;
}
.nav-cta:hover { border-color: var(--accent-border); background: var(--accent-muted); color: var(--text-1); }
.nav-cta svg { width: 0.9375rem; height: 0.9375rem; }
@media (max-width: 720px) { .nav-links a:not(.nav-cta) { display: none; } }
/* ------------------------------ hero ------------------------------ */
.hero {
position: relative;
padding: 9.5rem 0 5rem;
border-bottom: 1px solid var(--border);
overflow: hidden;
}
.hero::before {
content: "";
position: absolute; inset: 0;
background-image:
linear-gradient(to right, rgba(255,255,255,0.025) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 56px 56px;
mask-image: radial-gradient(ellipse 90% 70% at 50% 0%, black 30%, transparent 75%);
-webkit-mask-image: radial-gradient(ellipse 90% 70% at 50% 0%, black 30%, transparent 75%);
pointer-events: none;
}
.hero-inner { position: relative; text-align: center; }
.eyebrow {
display: inline-flex; align-items: center; gap: 0.5rem;
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-3);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.3125rem 0.875rem;
margin-bottom: 1.75rem;
}
.eyebrow .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); }
.hero h1 {
font-size: clamp(2.5rem, 6.5vw, 4.25rem);
font-weight: 600;
letter-spacing: -0.04em;
line-height: 1.05;
max-width: 17ch;
margin: 0 auto 1.25rem;
}
.hero h1 em { font-style: normal; color: var(--accent); }
.hero-sub {
font-size: 1.0625rem;
color: var(--text-2);
max-width: 34rem;
margin: 0 auto 2.5rem;
}
.hero-actions { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 0.5rem;
font-family: inherit;
font-size: 0.875rem; font-weight: 500;
border-radius: var(--radius);
padding: 0.625rem 1.125rem;
border: 1px solid transparent;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-primary { background: var(--accent); color: var(--accent-fg); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--surface); border-color: var(--border-strong); color: var(--text-1); }
.btn-secondary:hover { border-color: var(--text-3); }
.btn svg { width: 1rem; height: 1rem; }
.install {
display: inline-flex; align-items: center; gap: 0.875rem;
margin-top: 2rem;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.625rem 0.625rem 0.625rem 1rem;
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--text-2);
max-width: 100%;
}
.install .prompt { color: var(--accent); user-select: none; }
.install code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.install button {
flex: none;
display: inline-flex; align-items: center; gap: 0.375rem;
font-family: var(--font-mono); font-size: 0.6875rem; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--text-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.375rem 0.625rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.install button:hover { color: var(--text-1); border-color: var(--border-strong); }
.install button.copied { color: var(--success); border-color: var(--success); }
.install button svg { width: 0.8125rem; height: 0.8125rem; }
@media (max-width: 560px) {
.install { font-size: 0.6875rem; }
}
/* --------------------------- screenshot --------------------------- */
.shot-section { padding: 0 0 5.5rem; border-bottom: 1px solid var(--border); }
.shot {
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--bg-panel);
box-shadow: 0 24px 64px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.03);
margin-top: -1px;
}
.shot-bar {
display: flex; align-items: center; gap: 0.375rem;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid var(--border);
}
.shot-bar .led { width: 9px; height: 9px; border-radius: 50%; background: var(--surface-3); }
.shot-bar .url {
margin-left: 0.75rem;
font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-3);
background: var(--surface); border: 1px solid var(--border); border-radius: 5px;
padding: 0.1875rem 0.625rem;
}
.shot img { display: block; width: 100%; }
/* ---------------------------- sections ---------------------------- */
section.block { padding: 5.5rem 0; border-bottom: 1px solid var(--border); }
.section-head { margin-bottom: 3rem; max-width: 40rem; }
.section-head .kicker {
font-family: var(--font-mono); font-size: 0.6875rem;
letter-spacing: 0.14em; text-transform: uppercase;
color: var(--accent);
display: block; margin-bottom: 0.875rem;
}
.section-head h2 {
font-size: clamp(1.625rem, 3.5vw, 2.25rem);
font-weight: 600; letter-spacing: -0.03em; line-height: 1.15;
margin-bottom: 0.75rem;
}
.section-head p { color: var(--text-2); }
/* fact strip */
.facts {
display: grid; grid-template-columns: repeat(4, 1fr);
border: 1px solid var(--border); border-radius: var(--radius-lg);
overflow: hidden; background: var(--bg-panel);
}
.fact { padding: 1.5rem; border-left: 1px solid var(--border); }
.fact:first-child { border-left: 0; }
.fact .v { font-family: var(--font-mono); font-size: 1.375rem; font-weight: 500; color: var(--text-1); font-variant-numeric: tabular-nums; }
.fact .v em { font-style: normal; color: var(--accent); }
.fact .k { font-size: 0.75rem; color: var(--text-3); margin-top: 0.25rem; }
@media (max-width: 720px) {
.facts { grid-template-columns: 1fr 1fr; }
.fact:nth-child(3) { border-left: 0; }
.fact:nth-child(n+3) { border-top: 1px solid var(--border); }
}
/* feature grid */
.grid {
display: grid; grid-template-columns: repeat(3, 1fr);
border: 1px solid var(--border); border-radius: var(--radius-lg);
overflow: hidden; background: var(--bg-panel);
}
.cell {
padding: 1.75rem;
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
transition: background 0.15s;
}
.cell:hover { background: var(--surface); }
.cell:nth-child(3n+1) { border-left: 0; }
.cell:nth-child(-n+3) { border-top: 0; }
.cell .icon {
width: 2rem; height: 2rem;
display: flex; align-items: center; justify-content: center;
border: 1px solid var(--border-strong); border-radius: var(--radius);
color: var(--accent); margin-bottom: 1.125rem;
background: var(--accent-muted);
border-color: var(--accent-border);
}
.cell .icon svg { width: 1rem; height: 1rem; }
.cell h3 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 0.375rem; }
.cell p { font-size: 0.8125rem; color: var(--text-2); }
.cell .tag {
display: inline-block; margin-top: 0.875rem;
font-family: var(--font-mono); font-size: 0.625rem; letter-spacing: 0.08em;
color: var(--text-3); border: 1px solid var(--border); border-radius: 4px;
padding: 0.125rem 0.4375rem;
}
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr 1fr; }
.cell:nth-child(3n+1) { border-left: 1px solid var(--border); }
.cell:nth-child(odd) { border-left: 0; }
.cell:nth-child(-n+3) { border-top: 1px solid var(--border); }
.cell:nth-child(-n+2) { border-top: 0; }
}
@media (max-width: 560px) {
.grid { grid-template-columns: 1fr; }
.cell { border-left: 0 !important; border-top: 1px solid var(--border) !important; }
.cell:first-child { border-top: 0 !important; }
}
/* architecture */
.arch {
border: 1px solid var(--border); border-radius: var(--radius-lg);
background: var(--bg-panel);
padding: 2rem;
overflow-x: auto;
}
.arch pre {
font-family: var(--font-mono);
font-size: 0.8125rem;
line-height: 1.75;
color: var(--text-2);
min-width: 40rem;
}
.arch .hl { color: var(--accent); }
.arch .dim { color: var(--text-3); }
.arch .fg { color: var(--text-1); }
/* screenshots */
.shots { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
.shot-card {
border: 1px solid var(--border); border-radius: var(--radius-lg);
overflow: hidden; background: var(--bg-panel);
transition: border-color 0.15s;
}
.shot-card:hover { border-color: var(--border-strong); }
.shot-card img { display: block; width: 100%; border-bottom: 1px solid var(--border); }
.shot-card figcaption {
display: flex; align-items: baseline; justify-content: space-between;
padding: 0.875rem 1.125rem;
}
.shot-card figcaption b { font-size: 0.8125rem; font-weight: 600; }
.shot-card figcaption span { font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-3); }
@media (max-width: 720px) { .shots { grid-template-columns: 1fr; } }
/* ecosystem */
.eco { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.25rem; }
.eco-card {
border: 1px solid var(--border); border-radius: var(--radius-lg);
background: var(--bg-panel); padding: 1.75rem;
}
.eco-card h3 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 0.375rem; display: flex; align-items: center; gap: 0.5rem; }
.eco-card h3 svg { width: 1rem; height: 1rem; color: var(--accent); }
.eco-card p { font-size: 0.8125rem; color: var(--text-2); margin-bottom: 1rem; }
.eco-card code.snip {
display: block;
font-size: 0.75rem; color: var(--text-2);
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
padding: 0.625rem 0.875rem;
overflow-x: auto; white-space: nowrap;
}
.eco-card code.snip .p { color: var(--accent); }
@media (max-width: 720px) { .eco { grid-template-columns: 1fr; } }
/* final cta */
.cta { text-align: center; padding: 6rem 0; border-bottom: 1px solid var(--border); }
.cta h2 { font-size: clamp(1.75rem, 4vw, 2.5rem); font-weight: 600; letter-spacing: -0.03em; margin-bottom: 0.75rem; }
.cta p { color: var(--text-2); margin-bottom: 2rem; }
/* footer */
footer { padding: 2.5rem 0; }
.foot { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.foot .left { display: flex; align-items: center; gap: 0.625rem; font-size: 0.8125rem; color: var(--text-3); }
.foot .left img { width: 1.125rem; height: 1.125rem; border-radius: 4px; }
.foot .links { display: flex; gap: 1.5rem; font-size: 0.8125rem; color: var(--text-3); }
.foot .links a:hover { color: var(--text-1); }
</style>
</head>
<body>
<nav class="nav">
<div class="wrap nav-inner">
<a class="nav-brand" href="#top">
<img src="primora.svg" alt="Primora logo">
Primora
</a>
<div class="nav-links">
<a href="#features">Features</a>
<a href="#screenshots">Screenshots</a>
<a href="#stack">Stack</a>
<a href="#ecosystem">Ecosystem</a>
<a class="nav-cta" href="https://github.com/Dvorinka/Primora" rel="noopener">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
GitHub
</a>
</div>
</div>
</nav>
<header class="hero" id="top">
<div class="wrap hero-inner">
<span class="eyebrow"><span class="dot"></span>self-hosted &middot; MIT licensed</span>
<h1>Your backend, <em>your hardware</em>.</h1>
<p class="hero-sub">
Primora is a self-hosted backend platform — auth, Postgres-backed APIs,
S3-style object storage, JSON collections, scoped API keys, and a full
audit log behind one dashboard. No hosted tier.
</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#install">Get started</a>
<a class="btn btn-secondary" href="https://github.com/Dvorinka/Primora" rel="noopener">
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
View on GitHub
</a>
</div>
<div class="install" id="install">
<span class="prompt">$</span>
<code id="install-cmd">curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash</code>
<button id="copy-btn" type="button" aria-label="Copy install command">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span>copy</span>
</button>
</div>
</div>
</header>
<div class="shot-section">
<div class="wrap">
<figure class="shot">
<div class="shot-bar">
<span class="led"></span><span class="led"></span><span class="led"></span>
<span class="url">http://localhost</span>
</div>
<img src="screenshots/overview.png" alt="Primora project overview dashboard" loading="eager">
</figure>
</div>
</div>
<section class="block">
<div class="wrap">
<div class="facts">
<div class="fact"><div class="v"><em>1</em> cmd</div><div class="k">install — Docker Compose up</div></div>
<div class="fact"><div class="v"><em>0</em> hosted tiers</div><div class="k">data never leaves the box</div></div>
<div class="fact"><div class="v">MIT</div><div class="k">open source license</div></div>
<div class="fact"><div class="v"><em>100%</em> audit</div><div class="k">every mutation logged</div></div>
</div>
</div>
</section>
<section class="block" id="features">
<div class="wrap">
<div class="section-head">
<span class="kicker">// features</span>
<h2>Everything a backend needs. Nothing it doesn't.</h2>
<p>Inspired by the UX of Appwrite and Supabase — rebuilt as a single self-hosted stack you own outright.</p>
</div>
<div class="grid">
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg></div>
<h3>Organizations &amp; projects</h3>
<p>Workspaces with scoped org and project roles. Invite members by link, assign owner/admin/developer.</p>
<span class="tag">rbac</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg></div>
<h3>Auth</h3>
<p>Email/password via Better Auth, optional GitHub, Google, Discord and Microsoft OAuth. JWTs verified against JWKS.</p>
<span class="tag">better-auth + jwks</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/></svg></div>
<h3>Object storage</h3>
<p>S3-style buckets on your own filesystem, public or private. Upload, move, rename, inline preview.</p>
<span class="tag">local fs</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.66 3.58 3 8 3s8-1.34 8-3V5"/><path d="M4 12c0 1.66 3.58 3 8 3s8-1.34 8-3"/></svg></div>
<h3>Collections</h3>
<p>Schema-flexible JSON documents stored in Postgres JSONB. Query, create and update without migrations.</p>
<span class="tag">postgres jsonb</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-2 2m-7.6 7.6a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78Zm0 0L15.5 7.5m3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg></div>
<h3>Scoped API keys</h3>
<p><code class="mono">prm_&lt;prefix&gt;_&lt;secret&gt;</code> credentials, shown once at creation. Revocable, rate-limited per key.</p>
<span class="tag">prm_*</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/></svg></div>
<h3>Audit log</h3>
<p>Every mutating request recorded — actor, action, target. Filterable in the UI, exportable as CSV or JSON.</p>
<span class="tag">csv / json export</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h7l-1 8 10-12h-7l1-8Z"/></svg></div>
<h3>Automation</h3>
<p>Scheduled jobs and live event streams. Webhooks and connectors push project events to external systems.</p>
<span class="tag">schedules + webhooks</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg></div>
<h3>Instance admin</h3>
<p>Sign-up policy, mail transport, and rate limits managed in-app — no restart, secrets stored AES-256-GCM.</p>
<span class="tag">settings → instance</span>
</div>
<div class="cell">
<div class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg></div>
<h3>Demo mode</h3>
<p>Fully client-side seeded workspace — append <code class="mono">?demo=true</code> to the URL, no sign-up required.</p>
<span class="tag">?demo=true</span>
</div>
</div>
</div>
</section>
<section class="block" id="stack">
<div class="wrap">
<div class="section-head">
<span class="kicker">// stack</span>
<h2>Boring on purpose.</h2>
<p>One Postgres, one API, one process boundary per concern. Everything ships in Docker Compose.</p>
</div>
<div class="arch">
<pre><span class="fg">Browser</span> <span class="dim">──▶</span> <span class="fg">Nginx</span> <span class="dim">──┬──▶</span> <span class="hl">Frontend</span> <span class="dim">SolidJS + Vite</span>
<span class="dim">├──▶</span> <span class="hl">Auth</span> <span class="dim">Better Auth + Hono</span>
<span class="dim">└──▶</span> <span class="hl">API</span> <span class="dim">Go + Gin</span>
<span class="dim">│</span>
<span class="dim">├──▶</span> <span class="fg">PostgreSQL</span> <span class="dim">primary datastore</span>
<span class="dim">├──▶</span> <span class="fg">DragonflyDB</span> <span class="dim">cache + rate limits</span>
<span class="dim">└──▶</span> <span class="fg">Local FS</span> <span class="dim">object storage</span></pre>
</div>
</div>
</section>
<section class="block" id="screenshots">
<div class="wrap">
<div class="section-head">
<span class="kicker">// screenshots</span>
<h2>The console, as shipped.</h2>
<p>Real UI, no mockups. Every screen is dark-first, hairline-divided, and keyboard-driven.</p>
</div>
<div class="shots">
<figure class="shot-card">
<img src="screenshots/storage.png" alt="Object storage buckets and files" loading="lazy">
<figcaption><b>Storage</b><span>buckets / objects</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/collections.png" alt="JSON document collections" loading="lazy">
<figcaption><b>Collections</b><span>jsonb documents</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/telemetry.png" alt="Telemetry and component health" loading="lazy">
<figcaption><b>Telemetry</b><span>component health</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/automation.png" alt="Scheduled automation jobs" loading="lazy">
<figcaption><b>Automation</b><span>schedules</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/automation-live.png" alt="Live automation events" loading="lazy">
<figcaption><b>Live events</b><span>real-time stream</span></figcaption>
</figure>
<figure class="shot-card">
<img src="screenshots/audit.png" alt="Audit log with export" loading="lazy">
<figcaption><b>Audit log</b><span>csv / json export</span></figcaption>
</figure>
</div>
</div>
</section>
<section class="block" id="ecosystem">
<div class="wrap">
<div class="section-head">
<span class="kicker">// ecosystem</span>
<h2>Beyond the browser.</h2>
<p>The same API powers a CLI, an MCP server, a desktop shell, and an installable PWA.</p>
</div>
<div class="eco">
<div class="eco-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>CLI</h3>
<p><code class="mono">primora</code> manages orgs, projects, buckets, objects, keys, and audit from the terminal.</p>
<code class="snip"><span class="p">$</span> primora storage objects --bucket assets --limit 10</code>
</div>
<div class="eco-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg>MCP server</h3>
<p>Exposes <code class="mono">primora_*</code> tools to MCP clients — drive the platform from an agent.</p>
<code class="snip">primora_list_projects · primora_upload_object · …</code>
</div>
<div class="eco-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg>Desktop</h3>
<p>Tauri 2 shell that connects to your deployment, with tray presence and native notifications.</p>
<code class="snip">apps/desktop · tauri 2</code>
</div>
<div class="eco-card">
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/></svg>PWA</h3>
<p>The dashboard installs on mobile and desktop browsers — same console, native frame.</p>
<code class="snip">installable · offline-aware shell</code>
</div>
</div>
</div>
</section>
<section class="cta">
<div class="wrap">
<h2>Run it on your own box.</h2>
<p>Docker with the Compose plugin is the only prerequisite. Up in one command.</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#install">Install Primora</a>
<a class="btn btn-secondary" href="https://github.com/Dvorinka/Primora/releases" rel="noopener">Releases</a>
</div>
</div>
</section>
<footer>
<div class="wrap foot">
<div class="left">
<img src="primora.svg" alt="">
<span>Primora — MIT licensed</span>
</div>
<div class="links">
<a href="https://github.com/Dvorinka/Primora" rel="noopener">GitHub</a>
<a href="https://github.com/Dvorinka/Primora/blob/master/QUICK_START.md" rel="noopener">Docs</a>
<a href="https://github.com/Dvorinka/Primora/blob/master/CONTRIBUTING.md" rel="noopener">Contributing</a>
<a href="https://github.com/Dvorinka/Primora/blob/master/SECURITY.md" rel="noopener">Security</a>
</div>
</div>
</footer>
<script>
document.getElementById("copy-btn").addEventListener("click", async function () {
const cmd = document.getElementById("install-cmd").textContent;
try {
await navigator.clipboard.writeText(cmd);
} catch {
const ta = document.createElement("textarea");
ta.value = cmd;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
this.classList.add("copied");
this.querySelector("span").textContent = "copied";
setTimeout(() => {
this.classList.remove("copied");
this.querySelector("span").textContent = "copy";
}, 1600);
});
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#f4693a"/>
<g fill="#ffffff">
<rect x="15" y="14" width="34" height="10" rx="5"/>
<rect x="15" y="27" width="23" height="10" rx="5" opacity="0.7"/>
<rect x="15" y="40" width="10" height="10" rx="5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more