Author SHA1 Message Date
Tomas DvorakandDevin 86d612ec93 fix: lowercase GHCR owner in SBOM image refs
ghcr.io rejects mixed-case repo paths — syft failed to parse
ghcr.io/Dvorinka/primora-* so the v0.8.0 SBOM job errored.
Apply the same lowercase-owner normalization the image jobs use.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 13:52:51 +02:00
Tomáš Dvořák 0c5d4a72b2 Merge pull request #34 from Dvorinka/fix/release-native-arm-images
fix: build release images on native arch runners
2026-09-28 13:40:19 +02:00
Tomas DvorakandDevin 61e8c696c9 fix: build release images on native arch runners
arm64 npm ci under QEMU SIGILLs on current node:20-alpine layers
(uncaught target signal 4) and wedges the build step — the v0.8.0
release stalled there twice. Build each platform natively and join
the arch tags with a manifests job.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 13:37:56 +02:00
Tomáš Dvořák f6751b70d4 Merge pull request #33 from Dvorinka/release/v0.8.0
chore: release v0.8.0
2026-09-28 11:36:26 +02:00
Tomas DvorakandDevin 207486a53d chore: release v0.8.0
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-28 11:34:05 +02:00
Tomáš Dvořák aab4dd3bfd Merge pull request #32 from Dvorinka/feat/agent-setup-prompt
feat: agent setup prompt for new projects
2026-09-28 11:32:58 +02:00
Tomas DvorakandDevin 9dd6b40934 feat: agent setup prompt for new projects
A "Set up with an AI agent" dialog that mints a write-scoped API key and
renders a copyable prompt handing a coding agent everything needed to wire
a repository into a Primora project: telemetry ingest for any language,
db-connections for tracked databases, MCP/CLI agent access, and
verification steps. Opens at the end of new-project onboarding; also
reachable from the project dashboard and Projects page settings.

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-28 11:30:33 +02:00
Tomáš Dvořák dcc676bd52 Merge pull request #29 from Dvorinka/feat/hardening
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
security: harden agent surface — vault_exec allow-list, SSRF, error hygiene
2026-09-24 23:15:24 +02:00
Tomas Dvorak 8ff8a5fb26 Merge remote-tracking branch 'origin/feat/client-events' into feat/hardening 2026-09-24 23:12:45 +02:00
Tomas DvorakandDevin 9707f307a1 fix: silence errcheck on test-response writes
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 23:08:15 +02:00
Tomáš Dvořák 02d994c53b Merge pull request #18 from Dvorinka/feat/realtime-client
feat: realtime channel subscriptions in @primora/client
2026-09-24 23:04:27 +02:00
Tomáš Dvořák 0a3f4e2a57 Merge pull request #30 from Dvorinka/feat/document-query
feat: document query (replaces #17)
2026-09-24 23:04:10 +02:00
Tomáš Dvořák 03acb75f66 Merge pull request #16 from Dvorinka/feat/secrets-vault
feat: encrypted secrets vault — local + project, all surfaces
2026-09-24 23:00:26 +02:00
Tomas DvorakandDevin a71baa49cd test: regression coverage for error mapping and client error extraction
- handlers: pgx.ErrNoRows→404, InputError→400 with message preserved,
  pgconn 23505→409 without constraint/SQLSTATE leak, unknown→500
- services: export NewInputError so error construction is testable
- frontend: vitest for errorMessage helper — server reason preferred,
  status text and fallbacks verified

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 22:58:55 +02:00
Tomas DvorakandDevin 89f3d84234 fix: full-stack E2E hardening — client blobs, modal errors, audit coverage, MCP context
- api-client: getResponseBody returns Blob for non-JSON (fixes storage
  preview/download TypeError); postgen.mjs reapplies the patch on regen
- Modal: error prop renders inside the dialog so request failures are
  visible instead of hidden behind the overlay (6 pages wired)
- lib/api: extract server-provided error message from error.body
- Modal: unique id per instance (duplicate modal-title broke aria-label)
- nginx: index.html no-cache + immutable /assets (stale shell pointed at
  deleted hashed assets after redeploy → white screen)
- vite: drop dead Google Fonts runtime caching (fonts are vendored)
- functions: audit function.created/updated/deleted/invoked
- AuditPage: expand action filter to all recorded actions
- MCP: whoami uses /context for API keys; projectId/orgId resolve via
  /context with auth-aware precedence (stale `primora use` selection can
  never authorize a differently-scoped key)

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 22:51:10 +02:00
Tomas DvorakandDevin 182b0c49c2 fix: vendor dashboard fonts + vault_exec docs cleanup
- Geist/Geist Mono via @fontsource inside the Vite bundle — the
  Google Fonts <link> was blocked by the nginx CSP (style-src 'self'),
  so the dashboard silently fell back and phoned home on self-hosted
  instances. Fonts now serve from /assets under 'self' — CSP-clean,
  airgapped, no console violation.
- mcp README: vault_exec described the old deny-list; rewritten for
  the allow-list contract.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 22:01:47 +02:00
Tomas DvorakandDevin 2d6135121e security: harden agent surface — vault_exec allow-list, SSRF, error hygiene
Stress-test remediation across MCP, CLI, and backend:

- primora_vault_exec rewritten as an allow-list (was deny-list):
  colon-form single-token commands bypassed it, leaking every vault
  secret; verbatim env-name injection allowed NODE_OPTIONS=--require
  host RCE. Now fixed read-mostly command set, arg/path deny regex,
  strict env-name grammar, loader-hook name blocklist. All prior
  bypasses verified denied; allowed commands still inject.
- SSRF: webhook/job URL validation now blocks cloud metadata IPs
  (169.254.169.254, fd00:ec2::254) inside the link-local allowance
  and non-canonical IP encodings (decimal/hex bare hosts) smuggled
  as "docker service names".
- Path params encodeURIComponent in all client consumers — encodeURI
  left ?#& intact, smuggling query segments through {name} params.
- primora_upload_object strict-validates contentBase64 — Buffer.from
  silently dropped invalid chars.
- MCP no-creds startup degrades instead of crashing mid-handshake.
- GET /api/v1/context — API-key self-discovery; fixes the CLI dead
  end (login --api-key validates the key + seeds org/project,
  use/context/whoami work under key auth).
- CLI: unknown commands / bare group exit 1 with hints; mri numeric
  coercion reversed via raw-argv restore (007 stays "007");
  login --json; orgs:rm / projects:rm / buckets:rm behind --yes;
  fetch-failed errors name the cause.
- Error hygiene: pgx.ErrNoRows -> 404, pgconn codes -> clean
  400/409 without SQLSTATE leaks, validator failures formatted,
  services.InputError maps input faults to 400 (webhook URL/event
  errors were 500s), presign on local driver -> 400.
- Webhook events accept wildcard patterns (*, ns.*, custom.*)
  matched via SQL LIKE — client-published events can be webhooked.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 21:56:11 +02:00
Tomas DvorakandDevin 3b90dae767 docs: roadmap ledger for release hardening, sandbox driver, client events
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 20:56:37 +02:00
Tomas DvorakandDevin 236357facb feat: Client-published events + realtime presence
- POST /projects/:id/events accepts client-authored events restricted to
  the custom.* namespace — system types can't be spoofed. They flow
  through the same publishEvent fan-out: realtime, webhooks, and
  event_pattern functions all fire.
- Presence: subscribe/unsubscribe bumps a per-project count and
  broadcasts presence.update {online} to the realtime stream only —
  deliberately not through publishEvent so webhooks and "*" functions
  don't fire on connect noise. GET …/realtime/presence reads the count.
- @primora/client gains publish() and presence(); CLI gains events:send.

Verified live: presence 0->1 on subscribe, presence.update frame
delivered, custom event delivered to SSE + fired a custom.* function,
spoofed document.created rejected 400.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 20:55:48 +02:00
Tomas DvorakandDevin 9ae1c22af0 feat: Sandboxed function execution via FUNCTIONS_DRIVER=docker
Each invoke runs in an ephemeral locked-down container — no network,
read-only rootfs, 128MiB/0.5CPU/64-pid caps, non-root, cap-drop ALL.
Source travels as base64 in the container env and materializes in the
tmpfs, so no bind mount is needed (Docker Desktop / remote daemons work).
Timeout force-removes the named container.

exec driver stays the default; the trust-model docs now describe both.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 20:45:33 +02:00
Tomas DvorakandDevin edef002beb feat: Desktop auto-updater + release SBOMs
- tauri-plugin-updater + process: pubkey embedded in tauri.conf.json,
  createUpdaterArtifacts, endpoint = GitHub latest.json manifest.
- Connect screen checks before auto-connect (bounded 5s) — a saved
  URL would otherwise navigate away before the check resolves.
  "Update & restart" downloads, verifies minisign, installs, relaunches.
- Release workflow signs updater artifacts with
  TAURI_SIGNING_PRIVATE_KEY secrets, ships the .sig files, and assembles
  latest.json from them. No key → no manifest → no update, never a
  silent unsigned install.
- anchore/sbom-action emits CycloneDX SBOMs per GHCR image plus the
  source tree, attached to every release.

Note: the signing keypair was generated locally
(~/.tauri/primora-updater.key); the private half must be added to repo
secrets before the next release for updates to activate.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 20:41:16 +02:00
Tomas DvorakandDevin fd36389328 feat: Presigned object URLs (s3 driver)
POST /buckets/:id/object-presigns mints SigV4 query-signed GET/PUT
URLs so clients move bytes directly against S3 — large transfers skip
the backend entirely.

- storage.Presigner optional interface; S3Store implements, local
  driver returns a clear error (no external surface to sign).
- TTL 60s–1h (default 15m); download requires read role + existing
  object row (no key probing); upload requires write role.
- S3_PUBLIC_ENDPOINT rewrites the client-facing host for internal
  S3_ENDPOINTs — signatures bind the host, URLs are valid only where
  minted.
- CLI: primora objects:presign <bucket> <key> [--upload] [--ttl].

Verified live against cloudserver: GET fetched the object with no
auth headers; PUT pushed 1 MB direct to S3; unsigned list → 403;
untracked-key download presign → 404.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 20:35:21 +02:00
Tomas DvorakandDevin a84049b8fc feat: Function triggers — schedule, inbound hook, domain events
Migration 00016: function_id on scheduled_jobs/inbound_hooks,
event_pattern on functions, 'event' in the run-trigger enum.

- Jobs deliver to a function instead of HTTP when function_id is set;
  the run's status_code mirrors the process exit code. Clearing the
  field returns the job to URL delivery.
- Inbound hooks gain mode "function" — received body becomes stdin
  payload, acked immediately, run recorded async.
- event_pattern (document.*, *, exact type) fires the function detached
  on matching publishEvent — never blocks realtime/webhook fan-out.
- One executeFunction path serves manual/schedule/hook/event; run rows
  record the trigger source.
- Backend image now ships bun 1.3.14 so Functions work under compose.

Dashboard: job target picker, hook function mode, event-pattern field.

Verified live: job run → success (status_code 0), hook POST → run,
document.created → event run with correct payload.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 20:28:04 +02:00
Tomas DvorakandDevin c094afad65 docs: README/QUICK_START coverage for S3 driver and Functions
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 19:48:57 +02:00
Tomas DvorakandDevin 0a4ff1e6ae fix: compose healthchecks use 127.0.0.1, not localhost
Inside the frontend and nginx containers, localhost resolves to ::1
only while their nginx binds IPv4 — the wget healthcheck was refused
and both containers reported unhealthy despite serving fine.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 19:47:47 +02:00
Tomas DvorakandDevin 32587c8063 feat: Functions — JS/TS execution with run log
core.functions + core.function_runs: project-scoped code executed by a
runtime binary on the backend host (bun or deno per function). Payload
arrives on stdin + PRIMORA_PAYLOAD env; stdout/stderr captured per run
(capped), with status/exit code/duration. Invoke returns 502 + the
recorded run on failure so errors stay observable.

Dashboard gains a Functions page: code editor, JSON payload invoke,
live result, run history. Runner config via FUNCTIONS_RUNTIME /
FUNCTIONS_TIMEOUT_SECONDS / FUNCTIONS_MAX_OUTPUT_BYTES.

Verified live: bun ran code, stdin payload delivered, error path returns
502 with captured stderr; timeout kills at deadline.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 19:46:32 +02:00
Tomas DvorakandDevin 3aca5bd1e0 feat: S3-compatible object storage backend
BACKEND_STORAGE_DRIVER=s3 swaps LocalStore for a stdlib-only SigV4
implementation — PutObject/GetObject/DeleteObject/CopyObject/ListObjectsV2
against any S3-compatible API (AWS, MinIO, Garage, R2). Storage becomes a
Store interface; storage_path records s3://bucket/key. Content-Length is
resolved by seeking or temp-file spooling — chunked uploads are rejected
by real S3 servers. Bucket auto-created on startup.

Verified live against zenko/cloudserver: bucket, put, get, copy, delete.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 19:38:50 +02:00
Tomas DvorakandDevin f747b33b46 feat: HA scheduler leadership + templated email log
HA scheduler: the job scheduler and alert evaluator hold session-scoped
PostgreSQL advisory locks on pinned pool connections — replicas scan for
due work exactly once, failover verified live with two replicas. Manual
and hook-triggered runs stay available on any replica.

Email surface: mailer renders registered templates (invitation); every
send is logged to core.email_log with status and error text. New
GET /projects/:id/emails endpoint; Members page shows an Email log card.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 19:27:06 +02:00
Tomas DvorakandDevin 45a1621909 feat: inbound webhooks — public ingest endpoints
core.inbound_hooks (migration 00013): POST /api/v1/hooks/<token> accepts
external events. event mode republishes as inbound.received through the
signed dispatcher + realtime; job mode triggers a scheduled-job run with
the received body as payload. Optional X-Primora-Signature HMAC verify.
Dashboard card on the Integrations page.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 19:15:12 +02:00
Tomas DvorakandDevin 2f4de543a9 feat: telemetry alert rules with webhook delivery
core.alert_rules (migration 00012) — heartbeat_silence and error_spike
rules evaluated by a 60s backend ticker. Transition-based firing:
alert.fired on breach, alert.resolved on recovery, both first-class
webhook event types riding the signed dispatcher + realtime stream.
Dashboard manages rules on the Integrations page.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 19:09:19 +02:00
Tomas DvorakandDevin 345cf7918b feat: realtime channel subscriptions in @primora/client
createRealtimeClient opens the project SSE stream via fetch +
ReadableStream (EventSource cannot send X-API-Key), with capped
exponential-backoff reconnect and state callbacks. channel() filters by
event namespace — channel("documents").on("created", cb). Presence and
client-broadcast deliberately deferred.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 18:58:58 +02:00
Tomas DvorakandDevin 8892c6dc43 feat: PostgREST-style document filtering and ordering
GET /collections/:id/documents accepts filter (field.op.value terms; eq neq
gt gte lt lte like in is; dotted paths index into document data) and order
(field.asc|desc). Whitelisted field/operator parsing with parameterized
values; plain lists keep the sqlc path. CLI gains `documents list
--collection --filter --order`; OpenAPI + generated client updated.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-24 18:55:56 +02:00
Tomas DvorakandDevin f9e7002048 fix(mcp): resolve pre-existing tsc drift in server.ts
zod v4 requires z.record(keySchema, valueSchema); the integration tool
passed a string literal where the generated client wants the enum.

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-24 18:49:03 +02:00
Tomas DvorakandDevin 585bb04ab2 feat: encrypted secrets vault across CLI, desktop, dashboard, and backend
Local vault (apps/cli): clean-room PRMVLT01 format — Argon2id →
XChaCha20-Poly1305, AEAD-bound header, atomic writes. vault:* / secrets:*
/ inject (primora:// refs) / agent credential broker (dummy tokens in the
child, real secret on the wire) / TTL sessions / stack:* operator group.

Project vault (backend): core.project_secrets, AES-256-GCM via the
platform encryptor. Metadata-only list, audit-logged reveal, and
secret://NAME refs resolved at job delivery — refs stored, values never.

Surfaces: dashboard Vault page (+ local-vault card under the Tauri
shell), desktop vault window via IPC to the primora binary,
secrets:* --remote CLI mode, metadata/write-only MCP tools so agents
consume secrets via secret:// refs without holding plaintext.

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-24 18:46:37 +02:00
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
323 changed files with 23498 additions and 800 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
+36
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,33 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1
BACKEND_INTERNAL_URL=http://backend:8080
BACKEND_STORAGE_ROOT=/data/storage
# Object storage driver: "local" (default, uses BACKEND_STORAGE_ROOT) or "s3".
# For s3, point S3_ENDPOINT at any S3-compatible API (AWS, MinIO, Garage, R2).
BACKEND_STORAGE_DRIVER=local
#S3_ENDPOINT=http://minio:9000
#S3_REGION=us-east-1
#S3_BUCKET=primora-objects
#S3_ACCESS_KEY_ID=
#S3_SECRET_ACCESS_KEY=
#S3_PREFIX=
#S3_PATH_STYLE=true
# Client-facing S3 endpoint for presigned URLs — set when S3_ENDPOINT is an
# internal address (e.g. http://minio:9000 inside compose) that browsers
# can't reach. Presigned URLs are valid only at the host they were minted for.
#S3_PUBLIC_ENDPOINT=https://objects.example.com
# Functions execution. "exec" runs the runtime binary (bun/deno) directly on
# the backend host — simple, but function code inherits backend privileges.
# "docker" runs each invoke in an ephemeral locked-down container (no
# network, read-only fs, 128 MiB mem, non-root) and requires the Docker
# socket mounted into the backend plus the runtime images pulled.
#FUNCTIONS_DRIVER=exec
#FUNCTIONS_RUNTIME=auto
#FUNCTIONS_TIMEOUT_SECONDS=30
#FUNCTIONS_MAX_OUTPUT_BYTES=65536
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
# values win over these env vars; deleting the in-app value reverts here.
RESEND_API_KEY=
MAIL_FROM=Primora <[email protected]>
SMTP_HOST=mailpit
@@ -50,6 +85,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
+217 -19
View File
@@ -1,43 +1,82 @@
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 }}
name: Build + push ${{ matrix.app }} (${{ matrix.platform }})
needs: verify
runs-on: ubuntu-latest
if: needs.verify.outputs.needed == 'true'
# Native runners per arch — QEMU emulation SIGILLs on the current
# node:20-alpine arm64 layers (uncaught target signal 4) and wedges
# the build step indefinitely.
runs-on: ${{ matrix.platform == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
strategy:
fail-fast: false
matrix:
app: [backend, auth, frontend]
platform: [amd64, arm64]
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
@@ -48,16 +87,43 @@ jobs:
with:
context: .
file: apps/${{ matrix.app }}/Dockerfile
platforms: linux/${{ matrix.platform }}
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/primora-${{ matrix.app }}:${{ env.VERSION }}
ghcr.io/${{ github.repository_owner }}/primora-${{ matrix.app }}:latest
cache-from: type=gha,scope=${{ matrix.app }}
cache-to: type=gha,mode=max,scope=${{ matrix.app }}
tags: ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }}-${{ matrix.platform }}
cache-from: type=gha,scope=${{ matrix.app }}-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.app }}-${{ matrix.platform }}
# Joins the per-arch images into the real version + latest tags.
manifests:
name: Image manifests
needs: [verify, images]
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
steps:
- uses: docker/setup-buildx-action@v3
- name: Compute lowercase GHCR owner
run: echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create multi-arch manifests
env:
VERSION: ${{ needs.verify.outputs.version }}
run: |
set -e
for app in backend auth frontend; do
img="ghcr.io/${GHCR_OWNER}/primora-${app}"
docker buildx imagetools create \
-t "$img:$VERSION" -t "$img:latest" \
"$img:$VERSION-amd64" "$img:$VERSION-arm64"
done
cli-binaries:
name: Compile ${{ matrix.name }}
needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -87,6 +153,7 @@ jobs:
desktop:
name: Desktop bundle (${{ matrix.name }})
needs: verify
if: needs.verify.outputs.needed == 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -107,6 +174,11 @@ jobs:
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Build Tauri bundles
# Updater artifacts are minisign-signed; without the key the build
# still succeeds but emits no .sig files (updater can't verify them).
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run bundle --workspace @primora/desktop
- uses: actions/upload-artifact@v4
with:
@@ -116,13 +188,17 @@ jobs:
apps/desktop/src-tauri/target/release/bundle/**/*.deb
apps/desktop/src-tauri/target/release/bundle/**/*.rpm
apps/desktop/src-tauri/target/release/bundle/**/*.dmg
apps/desktop/src-tauri/target/release/bundle/**/*.app.tar.gz
apps/desktop/src-tauri/target/release/bundle/**/*.msi
apps/desktop/src-tauri/target/release/bundle/**/*.exe
apps/desktop/src-tauri/target/release/bundle/**/*.nsis.zip
apps/desktop/src-tauri/target/release/bundle/**/*.sig
if-no-files-found: error
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 +220,39 @@ jobs:
release:
name: GitHub Release
needs: [images, cli-binaries, desktop, npm-packages]
needs: [verify, manifests, cli-binaries, desktop, npm-packages]
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
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}" '
@@ -161,10 +261,108 @@ jobs:
flag
' CHANGELOG.md > notes.md
[ -s notes.md ] || echo "Release ${VERSION}" > notes.md
# Tauri updater manifest: one platform entry per signed updater
# artifact (AppImage.sig / app.tar.gz.sig / nsis.zip.sig). Missing
# sigs mean TAURI_SIGNING_PRIVATE_KEY isn't set — then there is no
# manifest and no update, which is correct (never ship unsigned).
- name: Build updater manifest (latest.json)
run: |
python3 - <<'PY'
import json, os, glob
base = "https://github.com/Dvorinka/Primora/releases/download/%s" % os.environ["VERSION"]
platforms = {}
for sig in glob.glob("dist-release/**/*.sig", recursive=True):
asset = sig[:-4]
name = os.path.basename(asset)
if name.endswith(".AppImage"):
plat = "linux-x86_64"
elif name.endswith(".app.tar.gz"):
plat = "darwin-aarch64" if "aarch64" in name else "darwin-x86_64"
if "aarch64" not in name and "x86_64" not in name:
plat = "darwin-aarch64" # macos-latest is arm64
elif name.endswith(".nsis.zip"):
plat = "windows-x86_64"
else:
continue
platforms[plat] = {
"signature": open(sig).read().strip(),
"url": f"{base}/{name}",
}
manifest = {
"version": os.environ["VERSION"],
"notes": "See the release notes.",
"pub_date": __import__("datetime").datetime.now(
__import__("datetime").timezone.utc
).strftime("%Y-%m-%dT%H:%M:%SZ"),
"platforms": platforms,
}
if platforms:
with open("dist-release/latest.json", "w") as f:
json.dump(manifest, f, indent=2)
print("latest.json:", ", ".join(platforms))
else:
print("no signed updater artifacts — skipping latest.json")
PY
- name: Create release
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[@]}"
# The manifest must be fetchable at /releases/latest/download/latest.json
if [ -f dist-release/latest.json ]; then
gh release upload "$VERSION" dist-release/latest.json --clobber
fi
# Software bill of materials — CycloneDX JSON per published image plus
# one for the source tree (npm + Go modules). Attached to the release
# next to the binaries; downstream consumers can verify provenance.
sbom:
name: SBOMs
needs: [verify, release]
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
permissions:
packages: read
env:
VERSION: ${{ needs.verify.outputs.version }}
steps:
- uses: actions/checkout@v4
# ghcr.io requires lowercase repo paths; the owner login may be mixed-case.
- name: Compute lowercase GHCR owner
run: echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: anchore/sbom-action@v0
with:
image: ghcr.io/${{ env.GHCR_OWNER }}/primora-backend:${{ needs.verify.outputs.version }}
format: cyclonedx-json
output-file: dist-release/sbom-backend.cdx.json
- uses: anchore/sbom-action@v0
with:
image: ghcr.io/${{ env.GHCR_OWNER }}/primora-auth:${{ needs.verify.outputs.version }}
format: cyclonedx-json
output-file: dist-release/sbom-auth.cdx.json
- uses: anchore/sbom-action@v0
with:
image: ghcr.io/${{ env.GHCR_OWNER }}/primora-frontend:${{ needs.verify.outputs.version }}
format: cyclonedx-json
output-file: dist-release/sbom-frontend.cdx.json
- uses: anchore/sbom-action@v0
with:
path: .
format: cyclonedx-json
output-file: dist-release/sbom-source.cdx.json
- name: Attach SBOMs to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$VERSION" dist-release/sbom-*.cdx.json --clobber
npm-publish:
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
+112
View File
@@ -4,6 +4,118 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
## [Unreleased]
## [0.8.0] - 2026-09-28
### Added
- **Agent setup prompt** — a "Set up with an AI agent" dialog that mints a fresh `write`-scoped API key (ingest + reads + non-admin mutations, never key-minting) and renders a copyable prompt handing a coding agent everything it needs: the deployment endpoint, project id, the key itself, plus tasks to instrument error/metric/log/heartbeat reporting per component (`POST /api/v1/ingest` — works from any language), register the app's databases via `db-connections` (postgres → cassandra), wire the MCP server / CLI for future agents, and verify with a test event + deploy marker. Opens at the end of the new-project onboarding and is re-reachable from the project dashboard and Projects page; keys are shown only inside the prompt (revoke under Settings → API keys).
- **Local secrets vault in the CLI** (`@primora/cli` 0.6.0) — `primora vault init/status` and `primora secrets set|get|list|rm|import` manage an encrypted `~/.config/primora/vault.bin` (Argon2id 64 MiB/3/4 → XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes). Entries carry value + optional `url`/`notes` metadata. Password via `PRIMORA_VAULT_PASSWORD`, `--password-file`, or hidden prompt; file path overridable with `PRIMORA_VAULT`. Clean-room format — no external vault compatibility.
- **Vault sessions** — `primora vault unlock --ttl 900` proves the password once and writes a TTL'd `vault.session` (0600); agents and scripts use the vault without the password. `primora vault lock` revokes immediately.
- **`primora agent` credential broker** — wraps a child process with dummy tokens and a loopback base URL; the proxy attaches the real secret on the wire per grant (TTL, cap 3600 s). Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`, `github`, `npm`; `--upstream name=https://…` covers arbitrary Bearer APIs. Base-URL broker, not TLS MITM — tools must honor a base-URL override.
- **`primora inject`** — runs a child process with vault secrets in its environment (`--all`, or `--env-file` templates containing `primora://NAME` refs). Nothing on disk, nothing in argv.
- **`primora stack:*` operator group** — `up`, `down`, `status`, `logs [service]`, `pull`, `backup` wrap `docker compose` in `--dir` (default cwd). `stack up --vault` materializes `.env` from the vault for the duration of the run (refuses to clobber an existing `.env` without `--force`, which swaps and restores it). `stack backup` writes a `pg_dump -Fc` of the platform database to `./backups`.
- **Vault MCP tools** (`@primora/mcp`) — `primora_vault_status`, `primora_vault_list` (metadata only — values are never returned), and `primora_vault_exec` which runs `primora` commands with a secret injected into the child environment; `vault`/`secrets`/`inject`/`agent`/`login`/`logout`/`use` and `keys:create` are denied so no response can carry a credential. (Hardened to a strict allow-list — see Security.)
- **Secrets Vault window in the desktop app** (`@primora/desktop`) — tray "Secrets Vault…" opens a dedicated window (`ui/vault.html`) covering init, timed unlock/lock with live countdown, secret list with copy-to-clipboard (values never displayed), add/delete, and `.env` import. IPC shells out to the `primora` binary (`PRIMORA_CLI` overrides), so desktop and terminal share one vault file and one crypto implementation; a missing CLI shows install guidance. Also reachable from the connect screen.
- **Project vault — server-side secrets** (`core.project_secrets`, migration 00011) — per-project secrets encrypted AES-256-GCM at rest via the existing encryptor. API: `GET/PUT/DELETE /projects/:projectID/secrets[/:name]` plus `POST …/reveal`. Lists return metadata only (name, url, notes, timestamps); reveal decrypts explicitly and is audit-logged (`secret.set`/`secret.revealed`/`secret.deleted`). Scheduled-job payloads reference secrets as `secret://NAME`, resolved to plaintext at delivery — refs are stored, values are not; unresolved or unconfigured refs fail the run loudly.
- **Vault page in the dashboard** — project secrets table with create/update, `.env` import, delete, and copy-to-clipboard reveal; plus a "This machine" card when running inside the desktop shell that drives the local vault through the same Tauri IPC commands as the vault window.
- **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values.
- **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext.
- **Realtime channel subscriptions** (`@primora/client`) — `createRealtimeClient({ endpoint, key, projectId })` opens the project SSE stream (`/realtime/stream`) via fetch + ReadableStream (EventSource can't send `X-API-Key`). `channel("documents").on("created", cb)` subscribes by event namespace (`document`/`object`/`issue`/`deploy`, `"*"` for all); auto-reconnects with capped exponential backoff; `onStateChange` reports `connecting|open|reconnecting|closed`.
- **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components.
- **Inbound webhooks** (`core.inbound_hooks`, migration 00013) — public ingest endpoints `POST /api/v1/hooks/<token>` (the token is the credential). `event` mode republishes the body as `inbound.received` domain events through the signed dispatcher + realtime; `job` mode triggers a scheduled-job run with the received body as its payload (`triggered_by: "hook"`). Optional `secret` requires `X-Primora-Signature` HMAC-SHA256 — the same scheme outbound deliveries use. Bodies capped at 256 KiB. Managed from Integrations → Webhooks.
- **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection <slug> --filter … --order …`.
- **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test.
- **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations.
- **S3-compatible object storage backend** — `BACKEND_STORAGE_DRIVER=s3` swaps the local disk store for any S3-compatible API (AWS, MinIO, Garage, R2) via a stdlib-only SigV4 implementation — no AWS SDK dependency. `S3_ENDPOINT`/`S3_REGION`/`S3_BUCKET`/credentials configure it; `S3_PATH_STYLE` (default on) covers MinIO/Garage, off for AWS virtual-hosted. The configured bucket is created on startup if missing; objects stream with computed SHA-256, `storage_path` records `s3://bucket/key`. Verified live end-to-end (bucket → put → get → copy → list → delete).
- **Functions** (`core.functions` + `core.function_runs`, migration 00015) — project-scoped JS/TS executed by a runtime binary on the backend host (`bun` or `deno`, per function). The JSON payload arrives on stdin and as `PRIMORA_PAYLOAD`; stdout/stderr are captured per run (capped at 64 KiB) with status, exit code and duration — `FUNCTIONS_RUNTIME`/`FUNCTIONS_TIMEOUT_SECONDS`/`FUNCTIONS_MAX_OUTPUT_BYTES` configure the runner. API: CRUD under `/projects/:id/functions`, `POST …/invoke` (502 + recorded run on failure), `GET …/runs`. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Code executes with backend privileges — the documented self-hosted trust model.
- **Presigned object URLs** — `POST /buckets/:bucketID/object-presigns` mints SigV4 query-signed `GET`/`PUT` URLs (60 s–1 h TTL) so clients transfer large objects directly against S3 without proxying through the backend. Read role for downloads (object must exist — no key probing), write role for uploads. `S3_PUBLIC_ENDPOINT` rewrites the client-facing host for internal `S3_ENDPOINT`s; signatures bind the host so URLs are valid only where they were minted. The local driver returns a clear error — a presigned URL needs a real object API.
- **Desktop auto-updater** — `tauri-plugin-updater` wired end to end: minisign keypair, `createUpdaterArtifacts`, `latest.json` manifest built from signed artifacts on every release, and a silent background check on the connect screen offering "Update & restart" (download → verify → install → relaunch). Unsigned artifacts can never install — missing signature means no manifest, not a bypass. CI signs via `TAURI_SIGNING_PRIVATE_KEY` secrets; OS-level signing (notarization/Authenticode) remains open.
- **Release SBOMs** — CycloneDX SBOM for each published GHCR image (backend/auth/frontend) plus one for the source tree, attached to every GitHub Release via `anchore/sbom-action`.
- **Sandboxed function driver** — `FUNCTIONS_DRIVER=docker` runs every invoke in an ephemeral locked-down container: no network, read-only rootfs, 128 MiB memory, 0.5 CPU, 64 pids, non-root, all capabilities dropped. Source is injected via base64 env (no bind mount — works on Docker Desktop and remote daemons); timeout force-removes the container so nothing lingers. Verified live: code runs, network is unreachable inside, and deadline kills land.
- **Client events + presence** — `POST /projects/:id/events` publishes client-authored events (`custom.*` namespace enforced — system types can't be spoofed) through the same fan-out as system events: realtime subscribers, matching webhooks, and `event_pattern` functions all fire. Realtime now tracks presence: `presence.update` {online} broadcasts on every subscribe/unsubscribe (stream-only — never reaches webhooks or functions), `GET …/realtime/presence` reads the count. Client SDK gains `publish()` and `presence()`; CLI gains `events:send`.
- **Function triggers** (migration 00016) — three new ways to run a function, all through the same executor and run log. Scheduled jobs accept `function_id` instead of `url` (run status mirrors the exit code); inbound hooks gain `mode: "function"` — the received body becomes the function's stdin payload, acked immediately with the run recorded async; and `event_pattern` on a function (`document.*`, `*`, or an exact type) fires it detached whenever a matching domain event publishes — `{event, project_id, occurred_at, data}` as payload. Runs record trigger source: `manual`/`schedule`/`hook`/`event`. Dashboard: function event-trigger field, job target picker (URL or function), and inbound-hook function mode.
- **`GET /api/v1/context`** — actor self-discovery: for API keys returns scopes, key prefix, and the resolved organization + project (id/slug/name). Fixes the API-key onboarding dead end: `primora login --api-key` now validates the key against the instance before saving and seeds org/project context automatically; `primora use`, `context`, and `whoami` work under API-key auth via this endpoint.
- **CLI `orgs:rm` / `projects:rm` / `buckets:rm`** — destructive deletes gated behind `--yes` in non-interactive mode, interactive confirm otherwise. `buckets:rm` resolves id/slug/name.
- **Webhook wildcard subscriptions** — `events` accepts `*`, `<namespace>.*`, and `custom.*` patterns, matched at dispatch time via SQL `LIKE` — the same semantics function `event_pattern` already used. Client-published `custom.*` events can now be webhooked.
### Security
- **`primora_vault_exec` rewritten as an allow-list** — the deny-list was bypassed by colon-form commands (`["secrets:get","X"]` as a single token evaded the pair check), exposing every vault secret; `--remote` variants revealed project secrets; and the `secret` param was used verbatim as an env name, allowing `NODE_OPTIONS=--require <file>` host code execution. Now only a fixed read-mostly command set runs (`whoami`, `context`, `orgs:list`, `projects:list`, `buckets:list`, `objects:list`, `documents:list`, `jobs:list`, `jobs:runs`, `audit:list`, `keys:list`, `events:send`), path/file flags and `..` args are rejected, injected env names must match `^[A-Z_][A-Z0-9_]{0,63}$`, and loader-hook names (`NODE_*`, `LD_*`, `DYLD_*`, `PATH`, `BASH*`, `PYTHON*`, `GIT_*`, `SSL_CERT*`, `XDG_*`, …) are blocked. Verified: every prior bypass denied, allowed commands still inject.
- **SSRF hardening on webhook/job URLs** — cloud metadata endpoints (`169.254.169.254`, `fd00:ec2::254`) are excluded from the link-local/private allowance, and pure decimal/hex "bare hostnames" (`2130706433`, `0x7f000001` — non-canonical IP encodings of loopback) are no longer treated as Docker service names.
- **Path params now `encodeURIComponent`** in all generated-client consumers (CLI, MCP, dashboard) — `encodeURI` left `?#&` intact, smuggling query segments through `{name}` params (e.g. `secrets/a?reveal=1`).
- **Strict base64 in `primora_upload_object`** — `Buffer.from(b64)` silently drops invalid characters; `contentBase64` is now validated (whitespace-tolerant) so corrupt input can't silently store garbage bytes.
- **MCP no-creds startup** — a missing config/API key previously threw inside `configureClient`, killing the process mid-handshake (protocol hang, stack on stderr). Startup now degrades: `initialize`/`tools/list` answer normally and every tool call returns the actionable error.
### Fixed
- **Silent CLI dispatch** — unknown commands, a bare `primora`, and group names without a verb exited 0 with no output. Now: unknown input errors with exit 1 (bare groups list their verbs), bare `primora` prints help.
- **Arg-parser numeric coercion** — `cac`/`mri` coerced numeric-looking flag values (`--value "007"` → `7`, `--value ""` → `0`), corrupting secrets and keys. Parsed options are now re-read as raw strings from argv for every non-boolean flag; `--value 007` round-trips intact.
- **`pgx.ErrNoRows` → 404** in the shared error mapper — missing resources returned raw "no rows in result set" 500s across all handlers.
- **Structured error hygiene** — `pgconn.PgError` codes map to clean statuses (23505 → 409 "resource already exists", 23503 → 409, 22001/54000 → 400 "value too large", 22021 → 400) instead of leaking SQLSTATE/constraint names; validator failures report `field is required`/`field failed <tag> validation`; a new `services.InputError` type maps service-layer input failures to 400 (webhook URL/event-type errors were 500s). Presign on the local driver returns `presign_unsupported` 400.
- **`fetch failed` polish** — connection failures report the cause code and suggest `primora context` instead of bare undici errors.
- **`login --json`** — the flag now emits JSON on both the session and API-key paths.
- **Dashboard fonts vendored** — Geist/Geist Mono now ship via `@fontsource` inside the bundle instead of a Google Fonts `<link>` the nginx CSP blocked (`style-src 'self'`). Removes the runtime Google dependency, works airgapped, and eliminates the console CSP violation.
- **Release image builds moved off QEMU** — arm64 `npm ci` under `docker/setup-qemu-action` SIGILLed on current `node:20-alpine` layers (`uncaught target signal 4`) and wedged the build step for over an hour. Images now build per-arch on native runners (`ubuntu-24.04-arm` for arm64) and a `manifests` job joins them into the multi-arch `:<version>`/`:latest` tags.
## [0.7.1] - 2026-09-23
### Fixed
- **Self-hosted sign-up no longer requires mail infrastructure** — `sendTransactionalEmail` warned-and-returned when no transport was configured instead of throwing, so SMTP-less instances 500'd on sign-up. `sendVerificationEmail` now also catches transport failures; a broken SMTP cannot block account creation (verification stays optional).
- **Password managers detect the login form** — the email/password fields gained `id`/`name` attributes and `label for`/`id` pairing, and the email input uses `autocomplete="username"` (Proton Pass et al.).
- **Installer prebuilt path strips build blocks** — `install.sh` previously kept `*-build` compose blocks when pulling prebuilt GHCR images.
### Added
- **Static landing page** (`landing/`) — self-contained marketing site with bundled Geist fonts and product screenshots; deployed separately from the app artifacts.
### Infrastructure
- **Automatic releases on merge** — merges to `master` whose root `package.json` version has no matching `v*` tag now trigger the full release pipeline (GHCR images, CLI binaries, desktop bundles, npm tarballs, GitHub Release). Tag pushes still work as before.
## [0.7.0] - 2026-09-21
### Changed
- **Public sign-up closes by default after the first account.** On upgrade, existing instances keep their users but new public sign-ups are rejected until an admin enables them (Settings → Instance or `SIGNUP_ENABLED=true`). The oldest existing account is not automatically promoted — set `AUTH_ADMIN_EMAILS` or update `user.role` to `admin` manually if no platform admin exists.
- **OAuth providers require `PRIMORA_MANAGED=true`.** Self-hosted deployments are email/password only; GitHub/Google/Discord/Microsoft env keys are ignored without the flag.
### Added
- **Instance administration** — `core.settings` (migration 00010) stores in-app overrides that resolve ahead of `.env` and built-in defaults. New endpoints: `GET /instance/public` (unauthenticated login-page state), `GET/PUT/DELETE /instance/settings[/key]` (platform admin only — better-auth `user.role`, not org role). Manageable keys cover public sign-up, mail transport (Resend or SMTP), and API/user rate limits. Secrets are AES-256-GCM encrypted at rest, never returned by the API, and decryptable by the auth service via the shared `PRIMORA_ENCRYPTION_KEY`.
- **Bootstrap + sign-up gating** — the first public sign-up becomes the instance admin; afterwards public sign-up is closed unless `SIGNUP_ENABLED=true` or the admin reopens it in Settings → Instance. `databaseHooks.user.create.before` gates only `/sign-up/email`, so admin-created users and OAuth flows bypass it.
- **Managed-tier OAuth gate** — `PRIMORA_MANAGED=true` is now required for GitHub/Google/Discord/Microsoft sign-in; self-hosted instances are email/password only. The login page hides the sign-up tab and social buttons according to `GET /instance/public`.
- **Settings → Instance tab** — platform admins edit sign-up, mail, and rate-limit settings in-app with source badges (in-app/env/default) and per-key reset.
## [0.6.0] - 2026-09-21
### Added
- **Scheduled jobs** — `core.scheduled_jobs` + `core.scheduled_job_runs` (migration 00009). Cron expressions and descriptors (`@hourly`, `@daily`, `@every 30m`, `*/15 * * * *`) parsed via robfig/cron; an in-process `JobScheduler` scans every 15 s, queues due jobs through a buffered channel, and collapses missed schedules into a single run. Endpoints under `/projects/:id/jobs`: list/create/update/delete, `GET …/jobs/:id/runs`, `POST …/jobs/:id/run` (manual execution). Jobs deliver a JSON envelope (event type, project/job/run IDs, trigger source, timestamp, static payload) via POST with `X-Primora-Signature: sha256=<hmac-sha256(body)>`, `X-Primora-Event`, `X-Primora-Job`, `X-Primora-Run` headers and a 10 s timeout. Secrets are AES-256-GCM sealed at rest; a generated secret is returned exactly once on create/rotate (`has_secret` thereafter). Run records carry status, trigger, HTTP status, duration, error text, and timestamps; history is pruned by retention. Reads require viewer+, writes admin/developer.
- **Domain events + realtime stream** — `PlatformService.publishEvent` fans project mutations out to realtime subscribers and matching webhooks through one path. New webhook event types: `job.run`, `document.created`/`updated`/`deleted`, `object.created`/`updated`/`deleted`. `GET …/projects/:projectID/realtime/stream` serves an SSE feed with 25 s keepalives and `?token=`/`?api_key=` query auth for `EventSource` clients.
- **Automation page** — SolidJS view with a Schedules tab (job table, create modal with schedule helper text, expandable run history, manual Run, enable/disable, delete, secret-shown-once banner) and a Live events tab (live/offline badge, pause, clear, auto-reconnect). Sidebar + command palette entries; demo-mode seeds and stubs included.
- **`primora jobs:*` CLI group** — `jobs:list`, `jobs:create <name> --schedule --url [--payload --secret --disabled]`, `jobs:run <job>`, `jobs:runs <job> --limit`, `jobs:rm <job> --yes`. Both `jobs list` and `jobs:list` spellings.
- **New `primora_*` MCP tools** — `primora_list_jobs`, `primora_create_job`, `primora_update_job`, `primora_delete_job`, `primora_list_job_runs`, `primora_run_job`. Webhook event schema extended to all new event types.
### Fixed
- **SSE broken by gzip middleware** — `Compression()` wrapped every `Accept-Encoding: gzip` response, and the gzip buffer swallowed each `Flush()`, so `EventSource` received headers then silence and closed. Paths ending in `/stream` now bypass compression; this repaired the pre-existing telemetry stream too.
- **Event payloads serialized as base64** — `document`/`object` fields inside domain-event envelopes were `json.RawMessage` placed into `map[string]any`, which marshals as base64. They're now wrapped so the raw document embeds in the event JSON.
- **Auth trusted origins ignored the configured public URL** — `docker-compose.yml` hardcoded `http://localhost` for `BETTER_AUTH_URL`, `AUTH_BASE_URL`, and `VITE_APP_URL`, so any non-default `NGINX_PORT` or domain broke sign-in with a 403. All three now read `.env` with localhost defaults.
- **Frontend auth base URL on non-default ports** — the bundled default baked `http://localhost`; `auth-client` now resolves `/auth` against `window.location.origin` (absolute, as better-auth requires) when `VITE_AUTH_BASE_URL` is unset.
### Security
- **Managed platform connections removed** — `platform-postgres`/`platform-dragonfly` seeds carried the backend's own `DATABASE_URL`/`DRAGONFLY_URL` into every project, where any member could run arbitrary queries through DBX against `core` tables. The seeds are gone and migration 00008 deletes existing managed rows. Operators keep direct `psql`/`redis-cli` access.
- **Connection passwords encrypted at rest** — `core.db_connections.config.password` is now AES-256-GCM sealed (`enc:v1:` marker) via `PRIMORA_ENCRYPTION_KEY`; decrypted only when registering the connection with DBX. Migration 00008 strips pre-existing plaintext passwords.
- **DBX naming** — connection names now prefix the full project UUID; the previous 8-hex prefix could collide across projects and alias the wrong credentials.
### Infrastructure
- **One-line installer** — `install.sh` (`curl | bash`) fetches the compose files, generates secrets, pulls prebuilt GHCR images, and falls back to a source-tarball build when packages aren't published.
- **CI image pipeline** — per-app buildx matrix with GHA layer cache, container-contents smoke checks, Trivy scans, `docker compose config` validation, and `edge`/`sha-*` GHCR publishing on master.
- **Fixed release image pushes** — tags were rendered with the mixed-case owner (`ghcr.io/Dvorinka/...`), which GHCR rejects; owner is now lowercased, unblocking tagged releases.
## [0.5.0] - 2026-09-16
### Added
+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`
+175
View File
@@ -0,0 +1,175 @@
# Primora Deployment Guide
Primora ships as a Docker Compose stack: nginx (edge), frontend (static SPA),
auth (Better Auth / Hono), backend (Go API), postgres, and dragonfly
(Redis-compatible cache). The base `docker-compose.yml` is the production
surface — dev-only services like Mailpit live in `docker-compose.dev.yml`.
## Prerequisites
- Docker Engine 24+ with the Compose plugin
- A host with 2 GB free RAM minimum (postgres + dragonfly + services)
- A domain pointing at the host if you want TLS
## Quick start
```bash
git clone <repo> && cd Primora
./scripts/setup.sh # writes .env with generated secrets
docker compose up -d # production surface only
```
For local development (includes Mailpit at `/mailpit/`):
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```
`setup.sh` generates `JWT_SECRET`, `BETTER_AUTH_SECRET`, and
`PRIMORA_ENCRYPTION_KEY`. Do not reuse the example values anywhere public.
## Environment reference
| Variable | Purpose | Required |
|---|---|---|
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | database credentials | yes |
| `DATABASE_URL` | backend → postgres DSN | yes |
| `DRAGONFLY_URL` | backend → cache DSN | yes |
| `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes |
| `BETTER_AUTH_SECRET` | auth session/signing secret | yes |
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for project secrets (Vault page, `secret://` job refs), integration credentials + webhook secrets at rest | production |
| `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container (local driver) | yes |
| `BACKEND_STORAGE_DRIVER` | `local` (default) or `s3` — S3-compatible object storage | no |
| `S3_ENDPOINT` / `S3_REGION` / `S3_BUCKET` | S3 API endpoint (empty endpoint → AWS `s3.<region>.amazonaws.com`), region, bucket | with `s3` |
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 credentials | with `s3` |
| `S3_PREFIX` / `S3_PATH_STYLE` | optional key prefix; path-style URLs (default `true`, needed by MinIO/Garage — set `false` for AWS virtual-hosted) | no |
| `S3_PUBLIC_ENDPOINT` | client-facing S3 endpoint used for presigned URLs when `S3_ENDPOINT` is internal-only (e.g. `http://minio:9000`); signatures bind the public host | no |
| `FUNCTIONS_DRIVER` | Function execution driver: `exec` (default — runtime binary on the backend host; code runs with backend privileges) or `docker` — each invoke runs in an ephemeral locked-down container (no network, read-only fs, 128 MiB, non-root). Docker mode needs `/var/run/docker.sock` mounted into the backend (see the commented volume in `docker-compose.yml`) and the runtime images pulled (`oven/bun`, `denoland/deno`). Source code is capped at 256 KiB under the docker driver | no |
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` / `FUNCTIONS_MAX_OUTPUT_BYTES` | Functions: runtime binary (`auto` resolves `bun`/`deno` — exec driver only; docker driver uses the matching official image), per-run timeout (default 30 s), captured-output cap (default 64 KiB) | no |
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
| `RESEND_API_KEY` | alternative to SMTP — Resend API | no |
| `*_CLIENT_ID` / `*_CLIENT_SECRET` | GitHub / Google / Discord / Microsoft OAuth | no |
| `NGINX_PORT` | host port for the edge (default 80) | no |
## TLS
Primora does not terminate TLS itself — put a reverse proxy in front of
nginx's port 80. The nginx config sets `X-Forwarded-*`-aware headers; pass
them through.
**Caddy** (`Caddyfile`):
```
primora.example.com {
reverse_proxy localhost:80
}
```
**Traefik** (compose labels on the nginx service):
```yaml
labels:
- traefik.enable=true
- traefik.http.routers.primora.rule=Host(`primora.example.com`)
- traefik.http.routers.primora.entrypoints=websecure
- traefik.http.routers.primora.tls.certresolver=letsencrypt
```
Once TLS is live, update `VITE_APP_URL`, `VITE_AUTH_BASE_URL`,
`AUTH_BASE_URL`, `BETTER_AUTH_URL`, and `COOKIE_DOMAIN` to the https origin
and rebuild the frontend image (`docker compose build frontend`).
## SMTP / email
Auth sends verification and password-reset email through SMTP
(`SMTP_*` + `MAIL_FROM`) or Resend (`RESEND_API_KEY`). In development the
`docker-compose.dev.yml` overlay runs Mailpit; mail is captured at
`http://localhost/mailpit/` and nothing leaves the host. **The production
compose file contains no mail sink — configure real SMTP before inviting
users.**
## Backups
`scripts/backup.sh` produces a tarball with a `pg_dump -Fc` of the database
and a tar of the `backend_storage` volume:
```bash
./scripts/backup.sh # → backups/primora-<stamp>.tar.gz
```
Restore (destructive — stops writes):
```bash
docker compose stop backend auth
./scripts/restore.sh backups/primora-<stamp>.tar.gz
docker compose up -d
```
Cron example (nightly, keep 14):
```cron
17 3 * * * cd /opt/primora && ./scripts/backup.sh && \
ls -1t backups/primora-*.tar.gz | tail -n +15 | xargs -r rm
```
systemd timer example is in `PRODUCTION_READINESS.md`.
## Upgrades
Migrations run automatically on backend boot (goose). Safe order:
```bash
./scripts/backup.sh # 1. snapshot first
docker compose pull && docker compose build # 2. new images
docker compose up -d # 3. rolling recreate; backend migrates on boot
./scripts/smoke-e2e.sh # 4. verify health, auth, ingest
```
`goose` migrations include `Down` sections; `goose -dir apps/backend/db/migrations postgres "$DATABASE_URL" down` rolls back one version if a release misbehaves.
## Storage constraint
Object storage defaults to the **local filesystem** (`backend_storage`
volume) — durable, backed-up disk on a single host. For multi-node or
external object stores set `BACKEND_STORAGE_DRIVER=s3` + the `S3_*`
variables above (AWS, MinIO, Garage, R2 all work).
## Functions trust model
Two drivers, two trust levels:
- **`exec` (default)** — function code runs as a child process of the
backend: same host, same privileges, no sandbox. Treat every project
member who can edit a function as holding a shell on the backend host.
Appropriate for single-team self-hosted deployments.
- **`docker`** — each invoke runs in an ephemeral container: no network,
read-only rootfs, 128 MiB memory, half a CPU, 64 pids, non-root, all
capabilities dropped. Requires the Docker socket mounted into the
backend (commented volume in `docker-compose.yml`) — note the socket is
itself root-equivalent on the host, so this isolates *function code*,
not operators. Source code capped at 256 KiB. For hostile multi-tenant
workloads, a dedicated microVM runtime (Firecracker/gVisor) remains the
roadmap item.
## Observability
- `GET /api/v1/metrics` — Prometheus exposition (unauthenticated; restrict at
the edge if the instance is public — see the commented rule in
`infra/nginx/default.conf`).
- `GET /api/v1/health/liveness` / `readiness` — compose healthchecks use the
same endpoints.
- Structured JSON logs on stdout for every service — ship with your normal
docker log driver.
## API key scopes
Keys are created with scopes: `ingest`, `read`, `write`, `admin`.
`admin` unlocks everything; `write` unlocks read + ingest + non-admin
mutations; `read`/`ingest` are single-purpose. Keys without stored scopes
(pre-scope keys) behave as `admin` for backwards compatibility — rotate them
into scoped keys at your leisure.
+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
```
+29 -2
View File
@@ -61,10 +61,37 @@ 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 |
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars, `openssl rand -hex 32`) — encrypts the project vault, integration credentials and webhook secrets at rest. Without it the Vault page and `secret://` job refs are unavailable |
| `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app |
| `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only |
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app |
| `BACKEND_STORAGE_DRIVER` | `local` (default, `BACKEND_STORAGE_ROOT`) or `s3` — S3-compatible object storage |
| `S3_ENDPOINT` / `S3_BUCKET` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 backend config (with `S3_REGION`, `S3_PREFIX`, `S3_PATH_STYLE`) |
| `S3_PUBLIC_ENDPOINT` | Client-facing S3 host for presigned URLs when `S3_ENDPOINT` is internal-only |
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` | JS runtime for Functions — `auto` (default, resolves `bun`/`deno` from PATH) or a binary path; 30 s default timeout |
| `VITE_DEMO_MODE` | Enable the client-side demo workspace |
### 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
+92 -98
View File
@@ -1,6 +1,65 @@
# Primora
<p align="center">
<img src="./primora.svg" alt="Primora" width="120">
</p>
A self-hosted backend platform for building products — auth, Postgres-backed APIs, object storage, JSON document collections, API keys, and a full audit log behind one dashboard. Inspired by the UX of Appwrite and Supabase, with no hosted tier: your data stays on your hardware.
<h1 align="center">Primora</h1>
<p align="center">
Self-hosted backend platform for building products.<br>
Auth, Postgres-backed APIs, object storage, and audit logs on your own hardware.
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="QUICK_START.md">Documentation</a> ·
<a href="https://github.com/Dvorinka/Primora/releases">Releases</a> ·
<a href="CONTRIBUTING.md">Contributing</a>
</p>
<p align="center">
<a href="https://github.com/Dvorinka/Primora/actions/workflows/ci.yml"><img src="https://github.com/Dvorinka/Primora/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/Dvorinka/Primora/releases"><img src="https://img.shields.io/github/v/release/Dvorinka/Primora" alt="Release"></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/Dvorinka/Primora" alt="License"></a>
</p>
## What is Primora?
Primora is a self-hosted backend platform: organizations, projects, auth,
S3-style object storage, JSON document collections, scoped API keys, and a
full audit log behind one dashboard. Inspired by the UX of Appwrite and
Supabase, with no hosted tier - your data stays on your hardware.
## Screenshots
| Overview | Storage | Telemetry |
|:-:|:-:|:-:|
| ![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 local disk or any S3-compatible backend (AWS, MinIO, Garage, R2), public or private. With `s3` the API mints presigned upload/download URLs so large objects skip the backend.
- **Collections** - schema-flexible JSON documents in Postgres JSONB, with filter/order queries.
- **Functions** - project-scoped JS/TS run by bun/deno — on the host (`exec`) or in ephemeral locked-down containers (`docker` driver) — with per-run logs. Trigger them four ways: manual invoke, cron schedules (`function_id` on a job), inbound hooks (`mode: "function"`), or domain events (`event_pattern` like `document.*`).
- **Vault** - AES-256-GCM project secrets plus an Argon2id/XChaCha20 local vault in the CLI and desktop app.
- **Automation** - scheduled jobs, inbound webhooks, telemetry alert rules, and `secret://` refs resolved at delivery.
- **Realtime** - SSE event stream with a typed client SDK (`@primora/client`) — channel subscriptions, presence counts, and client-published `custom.*` events that fan out to webhooks and functions.
- **API keys** - `prm_<prefix>_<secret>` credentials, shown once.
- **Agent setup** - every project can mint a `write`-scoped key and generate a copyable AI-agent prompt that wires telemetry, database tracking, and MCP/CLI access into any repository.
- **Audit log** - every mutating request recorded, with CSV/JSON export.
- **Demo mode** - fully client-side seeded workspace via `?demo=true`.
## Architecture
```
Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite)
@@ -10,116 +69,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, jobs, and audit — plus an encrypted local secrets vault (`vault:*`, `secrets:*`, `inject`), the shared project vault (`secrets:* --remote`, `secret://NAME` refs in job payloads), an agent credential broker (`agent`), and compose-stack operator commands (`stack:*`).
- **[MCP server](apps/mcp)** - exposes `primora_*` tools to MCP clients.
- **[Desktop](apps/desktop)** - Tauri 2 shell connecting to your deployment, with tray presence and a local Secrets Vault window (drives the CLI's vault).
- **PWA** - the dashboard is installable on mobile and desktop browsers.
## 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
+312 -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,319 @@ 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~~ ✅ — `tauri-plugin-updater`
wired: signed updater artifacts (`createUpdaterArtifacts`), minisign
keypair, `latest.json` manifest assembled on release, in-app
"Update & restart" on the connect screen. macOS/Windows OS-level code
signing (notarization/Authenticode) still open — needs paid certs.
## Phase 6 — Production hardening (v0.6.0) ✅
The v1 feature surface is essentially complete; what remains is the
difference between "works" and "safe to run other people's data". Roughly
three-quarters of the distance is travelled — this phase is the rest.
Foundations already in place: liveness/readiness probes with dependency
checks, per-key and per-user rate limiting, CORS locked to `PUBLIC_URL`,
AES-256-GCM credential encryption (`PRIMORA_ENCRYPTION_KEY` enforced in
production), audit log on mutations, org/project roles, SMTP + Resend in
the auth service, migrations on boot, single-command compose deploy,
release pipeline with images + binaries + desktop bundles.
### Security
- ~~**Graceful shutdown**~~ ✅ — `http.Server` + `signal.NotifyContext`,
10 s drain; verified via SIGTERM on the running container.
- ~~**Security headers**~~ ✅ — `X-Frame-Options`, `X-Content-Type-Options`,
`Referrer-Policy`, `Permissions-Policy` on every response; CSP scoped to
the SPA location only (verified live).
- ~~**TLS story**~~ ✅ — documented Caddy/Traefik front-proxy setups in
DEPLOYMENT_GUIDE.md; no embedded cert manager.
- ~~**Scoped API keys**~~ ✅ — `scopes` column (migration 00006), enforced
in auth middleware (`requiredAPIKeyScope`), UI checkboxes in Settings;
keys without stored scopes behave as `admin` (legacy). Verified live: a
`read`-only key gets 403 on ingest.
- ~~**Dev-surface isolation**~~ ✅ — Mailpit moved to
`docker-compose.dev.yml`; the base compose file cannot expose it.
- ~~**Auth brute-force**~~ ✅ — Better Auth's built-in rate limiting is now
enabled unconditionally (it previously followed `NODE_ENV`, which the
shipped `.env` sets to development); the IP-level 60 req/min limiter on
`/auth/*` stays in front.
- ~~**Setup ergonomics**~~ ✅ — `setup.sh` generates
`PRIMORA_ENCRYPTION_KEY` via `openssl rand -hex 32` alongside the other
secrets.
### Reliability & operations
- ~~**Backups + restore runbook**~~ ✅ — `scripts/backup.sh` (pg_dump -Fc +
storage volume tar) and `scripts/restore.sh`; restore verified against a
scratch postgres + volume. Cron/systemd examples in the docs.
- ~~**Retention**~~ ✅ — per-project `retention_{events,audit,webhook}_days`
(migration 00007, 0 = disabled) + hourly `RetentionSweeper`; settings
exposed in the project edit dialog.
- ~~**Platform metrics export**~~ ✅ — `GET /api/v1/metrics` renders
Prometheus text exposition (requests, errors, active, duration sum).
- ~~**Production compose profile**~~ ✅ — healthchecks + `restart:
unless-stopped` on every service, `depends_on: service_healthy`,
Dragonfly pinned to `v2.0.0`.
- ~~**Upgrade runbook**~~ ✅ — DEPLOYMENT_GUIDE.md documents backup → pull →
migrate-on-boot → verify, plus rollback via `goose down`.
### Correctness
- ~~**Pagination audit**~~ ✅ — every `:many` query audited; time-series and
entity lists are naturally bounded; `ListComponents` capped at 500
(auto-registers from ingest).
- ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres →
dragonfly transfer, key templating, TTL).
- ~~**External object storage**~~ ✅ — `BACKEND_STORAGE_DRIVER=s3` shipped
(Phase 10): any S3-compatible API + presigned URLs; local disk remains
the default.
### Testing
- ~~**E2E smoke in CI**~~ ✅ — `scripts/smoke-e2e.sh` + `e2e-smoke` job:
health, signup, token, bootstrap/org+project, scoped key create, ingest,
read-only key rejection, metrics. Verified locally against the stack.
- ~~**Migration tests**~~ ✅ — `migrations` job runs goose up → reset → up
against scratch postgres; verified locally.
- ~~**Load sanity**~~ ✅ — `scripts/load-sanity.js` (k6): ingest + events
read, 15 VUs / 30 s. Local run: 6650 requests, ~217 req/s, 0 real
failures, p95 221 ms — most requests beyond the configured 600/min cap
correctly returned 429.
### Distribution
- ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via
QEMU + buildx.
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle
verification; updater + minisign signing shipped (OS-level signing
remains open).
- ~~Optional: SBOM~~ ✅ — CycloneDX SBOMs per published image + source
tree, attached to every release (`anchore/sbom-action`).
### Docs
- ~~**Self-host guide**~~ ✅ — `DEPLOYMENT_GUIDE.md` (TLS, SMTP, backups,
upgrades, env reference, storage constraint) and
`PRODUCTION_READINESS.md` written; `verify-production-ready.sh` updated
to check them plus the Phase 6 artifacts.
**v1.0 bar:** every Security item done, a restore test actually run, E2E
smoke green in CI, and the upgrade runbook followed once on a real
upgrade. Docs site revisited here, not before.
## Phase 7 — Automation & realtime (v0.7.0) ✅
The last Supabase/Appwrite-parity gaps: scheduled work and a live view of
what the platform is doing. One event fan-out drives webhooks and the
dashboard's live feed off the same stream.
- **Scheduled jobs** — `core.scheduled_jobs` + `core.scheduled_job_runs`
(migration 00009). Cron expressions and descriptors (`@hourly`,
`@daily`, `@every 30m`, `*/15 * * * *`) via robfig/cron. In-process
`JobScheduler`: 15 s due-scan, buffered execution queue, missed
schedules collapse into one run, run history pruned by retention.
- **Signed delivery** — jobs POST a JSON envelope (event, project/job/run
IDs, trigger, timestamp, static payload) with
`X-Primora-Signature: sha256=<hmac-sha256(body)>`, 10 s timeout. Secrets
are AES-256-GCM sealed at rest and the generated value is returned
exactly once (`has_secret` thereafter).
- **Run history** — status (running/success/failed), trigger source,
HTTP status, duration, error text, timestamps; `POST …/jobs/:id/run`
for manual execution. Role-gated: viewers read, admin/developer write.
- **Domain events** — `publishEvent` fans document/object/issue/deploy/
job mutations out to realtime subscribers and matching webhooks.
Webhook event filter extended to `job.run`, `document.*`, `object.*`.
- **Realtime stream** — `GET …/projects/:projectID/realtime/stream` SSE,
25 s keepalives, `?token=`/`?api_key=` query auth for `EventSource`.
- **Automation page** — Schedules tab (table, create modal, run history
expansion, manual run, enable/disable, delete, secret-shown-once) and
Live events tab (live badge, pause, clear, reconnect). Demo-mode seeds
and stubs included; command palette entry added.
- **Surfaces** — `primora jobs:*` CLI group (list/create/run/runs/rm),
six new `primora_*_job*` MCP tools, `AutomationService` in the
generated client.
### Fixes folded in
- **gzip swallowed SSE** — the compression middleware buffered every
flush, so `EventSource` saw headers then nothing and died. `*/stream`
paths now bypass compression (telemetry stream was equally broken).
- **Trusted origins ignored `PUBLIC_URL`** — compose hardcoded
`http://localhost` for `BETTER_AUTH_URL`/`AUTH_BASE_URL`/`VITE_APP_URL`;
they now read env so a non-default port/domain actually works.
- **Relative auth base URL** — better-auth rejects relative URLs;
`auth-client` now resolves `/auth` against `window.location.origin`,
preserving same-origin defaults without breaking split-origin dev.
## Secrets vault (unreleased)
Arca-inspired secrets management, clean-room — every surface covered.
- **Local vault** (`apps/cli`) — `PRMVLT01` format: Argon2id (64 MiB/3/4) →
XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes. `vault:*`,
`secrets:*`, `inject` (env injection + `primora://` refs), TTL sessions,
and the `agent` credential broker (dummy tokens in the child, real secret
attached on the wire per grant).
- **Project vault** (`core.project_secrets`, migration 00011) — AES-256-GCM
via the platform encryptor; metadata-only list, audit-logged reveal;
`secret://NAME` refs in job payloads resolve at delivery and fail loudly
when unresolved.
- **Surfaces** — dashboard Vault page (+ local-vault card under the desktop
shell), desktop vault window via Tauri IPC → `primora` binary,
`secrets:* --remote` CLI mode, and MCP tools that are metadata/write-only
so agents never hold plaintext.
## Phase 11 — Document queries (unreleased)
PostgREST-style filtering and ordering on collection documents.
- `GET /collections/:id/documents?filter=…&order=…` — `filter` is a
comma-separated `field.op.value` list (ops `eq neq gt gte lt lte like in
is`), `order` a `field.asc|field.desc` list. Dotted paths index into
document data (`meta.city.eq.Prague`); `id`/`created_at`/`updated_at` are
filterable columns.
- Whitelisted field/operator parsing, parameterized values, JSONB
extraction — no interpolated SQL. Plain lists keep the sqlc path.
- `primora documents list --collection <slug> --filter … --order …` in the
CLI; `filter`/`order` params in the generated client.
## Phase 8 — Realtime client SDK (unreleased)
Channel subscriptions over the Phase 7 SSE stream, in `@primora/client`.
- `createRealtimeClient({ endpoint, key, projectId })` — fetch +
ReadableStream reader (EventSource can't send `X-API-Key`), auto-reconnect
with capped backoff, `onStateChange` lifecycle.
- `channel("documents").on("created", cb)` — namespaces `document`, `object`,
`issue`, `deploy`, `"*"` for everything; trailing `s` optional.
- **Presence** ✅ — server tracks realtime subscribers per project;
`presence.update` {online: n} broadcasts on every join/leave (realtime-only,
never fans out to webhooks/functions), `GET …/realtime/presence` reads the
count, `client.presence()` wraps it.
- **Client events** ✅ — `POST /projects/:id/events` publishes `custom.*`
types through the standard fan-out (realtime + webhooks + functions);
`client.publish("custom.x", data)` and `primora events:send` both reach it.
The `custom.*` prefix is enforced so clients can't spoof system events.
## Notifications — alert rules (unreleased)
Telemetry-driven alerts delivered through the existing webhook dispatcher.
- `core.alert_rules` (migration 00012) — `heartbeat_silence` and
`error_spike` kinds, per-component or project-wide scope, evaluated by a
60 s backend ticker.
- Transition-based firing: `alert.fired` once on breach, `alert.resolved`
on recovery — no notification storms. Both are webhook event types and
realtime events, so any existing subscriber receives them signed.
- CRUD via `/projects/:id/alerts`; Integrations page manages rules next to
webhooks and shows live `firing` state.
## Inbound webhooks (unreleased)
Project-scoped public ingest for external systems.
- `POST /api/v1/hooks/<token>` — token is the credential; optional
`X-Primora-Signature` HMAC-SHA256 verification when a secret is set.
- `event` mode republishes the body as `inbound.received` (webhooks +
realtime fan out for free); `job` mode enqueues a scheduled-job run with
the received body as payload (`triggered_by: "hook"`).
- 256 KiB body cap; `last_received_at` tracked; managed from Integrations.
## HA scheduler (unreleased)
Multi-replica safety for the job scheduler and alert evaluator.
- PostgreSQL advisory locks (session-scoped, separate keys per component)
on a dedicated pinned pool connection; only the leader scans for due work.
- Manual runs and hook-triggered runs work on any replica — the lock gates
only the scheduled scan, so leadership loss never blocks on-demand work.
- Released on shutdown; failover verified with a live two-replica test —
the survivor acquired leadership and fired the due job.
## Email surface (unreleased)
Reusable templates + an operational send log.
- `core.email_log` (migration 00014) — every send recorded: template,
recipient, subject, `sent`/`failed` status, error text.
- `Mailer` stays transport-only (Resend when configured, SMTP otherwise);
templates render subject + plaintext body. First template: `invitation`.
- `GET /projects/:id/emails` lists recent sends; Members page shows an
"Email log" card under pending invitations.
## Future phases — candidates
Ordered loosely by leverage. None committed; each gets scoped when picked.
- **Phase 9 — Functions** — ✅ shipped: `core.functions` +
`core.function_runs`, bun/deno exec runner (stdin payload, env injection,
capped output, timeout), invoke API + dashboard page. Triggers shipped:
scheduled jobs (`function_id` on a job), inbound hooks (`mode:
"function"`), and domain events (`event_pattern`, e.g. `document.*`).
Runs record trigger source (`manual`/`schedule`/`hook`/`event`).
Isolation shipped: `FUNCTIONS_DRIVER=docker` runs every invoke in an
ephemeral locked-down container (no network, read-only fs, non-root).
Remaining candidate: Firecracker/gVisor microVMs for hostile
multi-tenant workloads — docker socket trust is documented.
- **Phase 10 — Storage backends** — ✅ shipped: `BACKEND_STORAGE_DRIVER=s3`
+ `S3_*` env vars, stdlib SigV4, verified against a real S3 server.
Presigned upload/download URLs shipped: `POST
/buckets/:id/object-presigns` mints SigV4 query-signed GET/PUT URLs
(`S3_PUBLIC_ENDPOINT` rewrites the client-facing host); local driver
returns a clear error — a presigned URL needs a real object API behind it.
- **HA mode** — ✅ shipped: PostgreSQL advisory-lock leadership for the
scheduler and alert evaluator; failover verified with two replicas.
- **Email surface** — ✅ shipped: templated sends + `core.email_log` +
dashboard card; more templates land as new flows need them.
## Release hardening (unreleased)
- **Desktop auto-updater** — `tauri-plugin-updater`: signed updater
artifacts, `latest.json` manifest assembled per release, in-app
"Update & restart" on the connect screen. Unsigned artifacts can never
install — no signature means no manifest.
- **SBOMs** — CycloneDX per GHCR image + source tree, attached to every
GitHub Release.
- Requires `TAURI_SIGNING_PRIVATE_KEY` repo secret (keypair generated
locally; private half never committed).
## Sandboxed functions (unreleased)
- `FUNCTIONS_DRIVER=docker` — every invoke runs in an ephemeral sibling
container: `--network none`, read-only rootfs, 128 MiB / 0.5 CPU / 64
pids, non-root, `--cap-drop ALL`. Source travels as base64 env — no
bind mount, so Docker Desktop and remote daemons work. Timeout
force-removes the named container.
- `exec` remains the default for single-team deployments; the trust-model
doc describes both levels.
## Client events + presence (unreleased)
- `POST /projects/:id/events` — members/API keys publish `custom.*`
events through the standard fan-out (realtime + webhooks + functions);
system types can't be spoofed.
- Presence — per-project realtime subscriber counts; `presence.update`
broadcasts on join/leave (stream-only), `GET …/realtime/presence`
reads the count. SDK: `publish()` + `presence()`; CLI: `events:send`.
---
## What is deliberately out of scope
- 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)");
}
+3
View File
@@ -34,6 +34,9 @@ RUN apt-get update \
&& chmod +x /usr/local/bin/dbx-mcp \
&& rm /tmp/dbx-mcp.tar.gz
# Functions runtime — bun static binary from the official image (multi-arch).
COPY --from=oven/bun:1.3.14 /usr/local/bin/bun /usr/local/bin/bun
COPY --from=builder /primora-backend /usr/local/bin/primora-backend
COPY --from=builder /app/db ./db
COPY --from=builder /app/openapi ./openapi
@@ -0,0 +1,6 @@
-- +goose Up
ALTER TABLE core.api_keys
ADD COLUMN scopes TEXT[] NOT NULL DEFAULT '{admin}';
-- +goose Down
ALTER TABLE core.api_keys DROP COLUMN IF EXISTS scopes;
@@ -0,0 +1,11 @@
-- +goose Up
ALTER TABLE core.projects
ADD COLUMN retention_events_days INT NOT NULL DEFAULT 90,
ADD COLUMN retention_audit_days INT NOT NULL DEFAULT 365,
ADD COLUMN retention_webhook_days INT NOT NULL DEFAULT 30;
-- +goose Down
ALTER TABLE core.projects
DROP COLUMN IF EXISTS retention_events_days,
DROP COLUMN IF EXISTS retention_audit_days,
DROP COLUMN IF EXISTS retention_webhook_days;
@@ -0,0 +1,11 @@
-- +goose Up
-- Managed seeds stored the platform's own DATABASE_URL/DRAGONFLY_URL in
-- tenant-visible rows; the feature is removed, so drop the leaked rows.
DELETE FROM core.db_connections WHERE is_managed;
-- Passwords are AES-GCM sealed app-side from now on; strip any plaintext
-- password left in existing configs (users re-enter on next edit).
UPDATE core.db_connections SET config = config - 'password' WHERE config ? 'password';
-- +goose Down
-- Deleted managed rows and stripped passwords are not recoverable.
SELECT 1;
@@ -0,0 +1,39 @@
-- +goose Up
CREATE TABLE core.scheduled_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
schedule TEXT NOT NULL,
url TEXT NOT NULL,
secret BYTEA NOT NULL DEFAULT ''::bytea,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_run_at TIMESTAMPTZ,
last_status TEXT NOT NULL DEFAULT '' CHECK (last_status IN ('', 'success', 'failed')),
next_run_at TIMESTAMPTZ,
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
CREATE INDEX idx_scheduled_jobs_due ON core.scheduled_jobs(next_run_at)
WHERE enabled AND next_run_at IS NOT NULL;
CREATE TABLE core.scheduled_job_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID NOT NULL REFERENCES core.scheduled_jobs(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'success', 'failed')),
triggered_by TEXT NOT NULL DEFAULT 'schedule' CHECK (triggered_by IN ('schedule', 'manual')),
status_code INT,
error TEXT NOT NULL DEFAULT '',
duration_ms INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ
);
CREATE INDEX idx_scheduled_job_runs_job ON core.scheduled_job_runs(job_id, created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS core.scheduled_job_runs;
DROP TABLE IF EXISTS core.scheduled_jobs;
@@ -0,0 +1,14 @@
-- +goose Up
-- Instance-level settings. In-app values override env vars; secrets are
-- AES-256-GCM ciphertext (base64) produced by the backend's Encryptor and
-- readable by the auth service, which shares PRIMORA_ENCRYPTION_KEY.
CREATE TABLE core.settings (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
secret BOOLEAN NOT NULL DEFAULT FALSE,
updated_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- +goose Down
DROP TABLE IF EXISTS core.settings;
@@ -0,0 +1,19 @@
-- +goose Up
-- Project-level secrets: AES-256-GCM ciphertext produced by the backend's
-- Encryptor. Values are write-once — list/read expose metadata only; a
-- dedicated reveal endpoint decrypts and is audit-logged. secret://NAME
-- references in job payloads resolve at delivery time.
CREATE TABLE core.project_secrets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
ciphertext BYTEA NOT NULL,
url TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
-- +goose Down
DROP TABLE IF EXISTS core.project_secrets;
@@ -0,0 +1,16 @@
-- +goose Up
CREATE TABLE core.alert_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('heartbeat_silence','error_spike')),
config JSONB NOT NULL DEFAULT '{}'::jsonb,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
state JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
-- +goose Down
DROP TABLE IF EXISTS core.alert_rules;
@@ -0,0 +1,29 @@
-- +goose Up
CREATE TABLE core.inbound_hooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
mode TEXT NOT NULL DEFAULT 'event' CHECK (mode IN ('event','job')),
job_id UUID REFERENCES core.scheduled_jobs(id) ON DELETE CASCADE,
secret BYTEA NOT NULL DEFAULT ''::bytea,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_received_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
ALTER TABLE core.scheduled_job_runs
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
ALTER TABLE core.scheduled_job_runs
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
CHECK (triggered_by IN ('schedule','manual','hook'));
-- +goose Down
ALTER TABLE core.scheduled_job_runs
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
ALTER TABLE core.scheduled_job_runs
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
CHECK (triggered_by IN ('schedule','manual'));
DROP TABLE IF EXISTS core.inbound_hooks;
@@ -0,0 +1,16 @@
-- +goose Up
CREATE TABLE core.email_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES core.projects(id) ON DELETE CASCADE,
template TEXT NOT NULL,
to_email TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('sent','failed')),
error TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_email_log_project ON core.email_log(project_id, created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS core.email_log;
@@ -0,0 +1,40 @@
-- +goose Up
CREATE TABLE core.functions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
code TEXT NOT NULL,
runtime TEXT NOT NULL DEFAULT 'bun' CHECK (runtime IN ('bun','deno')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
CREATE TABLE core.function_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
function_id UUID NOT NULL REFERENCES core.functions(id) ON DELETE CASCADE,
trigger TEXT NOT NULL CHECK (trigger IN ('manual','schedule','hook')),
status TEXT NOT NULL CHECK (status IN ('success','error','timeout')),
exit_code INT,
stdout TEXT NOT NULL DEFAULT '',
stderr TEXT NOT NULL DEFAULT '',
duration_ms INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX function_runs_function_idx ON core.function_runs(function_id, created_at DESC);
ALTER TABLE core.scheduled_job_runs
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
ALTER TABLE core.scheduled_job_runs
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
CHECK (triggered_by IN ('schedule','manual','hook','function'));
-- +goose Down
ALTER TABLE core.scheduled_job_runs
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
ALTER TABLE core.scheduled_job_runs
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
CHECK (triggered_by IN ('schedule','manual','hook'));
DROP TABLE IF EXISTS core.function_runs;
DROP TABLE IF EXISTS core.functions;
@@ -0,0 +1,34 @@
-- +goose Up
ALTER TABLE core.scheduled_jobs
ADD COLUMN function_id UUID REFERENCES core.functions(id) ON DELETE SET NULL;
ALTER TABLE core.inbound_hooks
ADD COLUMN function_id UUID REFERENCES core.functions(id) ON DELETE CASCADE;
ALTER TABLE core.inbound_hooks
DROP CONSTRAINT inbound_hooks_mode_check;
ALTER TABLE core.inbound_hooks
ADD CONSTRAINT inbound_hooks_mode_check
CHECK (mode IN ('event','job','function'));
ALTER TABLE core.functions
ADD COLUMN event_pattern TEXT NOT NULL DEFAULT '';
ALTER TABLE core.function_runs
DROP CONSTRAINT function_runs_trigger_check;
ALTER TABLE core.function_runs
ADD CONSTRAINT function_runs_trigger_check
CHECK (trigger IN ('manual','schedule','hook','event'));
-- +goose Down
ALTER TABLE core.function_runs
DROP CONSTRAINT function_runs_trigger_check;
ALTER TABLE core.function_runs
ADD CONSTRAINT function_runs_trigger_check
CHECK (trigger IN ('manual','schedule','hook'));
ALTER TABLE core.functions DROP COLUMN IF EXISTS event_pattern;
ALTER TABLE core.inbound_hooks
DROP CONSTRAINT inbound_hooks_mode_check;
ALTER TABLE core.inbound_hooks
ADD CONSTRAINT inbound_hooks_mode_check CHECK (mode IN ('event','job'));
ALTER TABLE core.inbound_hooks DROP COLUMN IF EXISTS function_id;
ALTER TABLE core.scheduled_jobs DROP COLUMN IF EXISTS function_id;
+49
View File
@@ -0,0 +1,49 @@
-- name: CreateAlertRule :one
INSERT INTO core.alert_rules (project_id, name, kind, config)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: ListAlertRules :many
SELECT * FROM core.alert_rules
WHERE project_id = $1
ORDER BY name;
-- name: ListEnabledAlertRules :many
SELECT * FROM core.alert_rules
WHERE enabled;
-- name: GetAlertRule :one
SELECT * FROM core.alert_rules
WHERE id = $1 AND project_id = $2;
-- name: UpdateAlertRule :one
UPDATE core.alert_rules
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING *;
-- name: SetAlertRuleState :exec
UPDATE core.alert_rules
SET state = $3, updated_at = NOW()
WHERE id = $1 AND project_id = $2;
-- name: DeleteAlertRule :exec
DELETE FROM core.alert_rules
WHERE id = $1 AND project_id = $2;
-- Per-component most recent heartbeat — heartbeat_silence detection.
-- name: LastHeartbeats :many
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
FROM core.components co
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
WHERE co.project_id = $1
GROUP BY co.name;
-- Per-component error count inside a sliding window — error_spike detection.
-- name: RecentErrorCounts :many
SELECT co.name AS component_name, count(*) AS errors
FROM core.events e
JOIN core.components co ON co.id = e.component_id
WHERE e.project_id = $1 AND e.type = 'error'
AND e.ts > now() - make_interval(secs => $2::bigint)
GROUP BY co.name;
+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
+10
View File
@@ -0,0 +1,10 @@
-- name: InsertEmailLog :one
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: ListEmailLog :many
SELECT * FROM core.email_log
WHERE project_id = $1
ORDER BY created_at DESC
LIMIT $2;
+45
View File
@@ -0,0 +1,45 @@
-- name: CreateFunction :one
INSERT INTO core.functions (project_id, name, code, runtime, event_pattern)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: GetFunction :one
SELECT * FROM core.functions WHERE id = $1;
-- name: GetFunctionByName :one
SELECT * FROM core.functions WHERE project_id = $1 AND name = $2;
-- name: ListFunctions :many
SELECT * FROM core.functions
WHERE project_id = $1
ORDER BY name;
-- name: UpdateFunction :one
UPDATE core.functions
SET code = COALESCE(sqlc.narg('code'), code),
enabled = COALESCE(sqlc.narg('enabled'), enabled),
event_pattern = COALESCE(sqlc.narg('event_pattern'), event_pattern),
updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING *;
-- name: DeleteFunction :exec
DELETE FROM core.functions WHERE id = $1 AND project_id = $2;
-- name: InsertFunctionRun :one
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: ListFunctionRuns :many
SELECT * FROM core.function_runs
WHERE function_id = $1
ORDER BY created_at DESC
LIMIT $2;
-- name: ListFunctionsForEvent :many
SELECT * FROM core.functions
WHERE project_id = $1
AND enabled = TRUE
AND event_pattern <> ''
AND ($2 LIKE REPLACE(event_pattern, '*', '%') OR event_pattern = '*');
+26
View File
@@ -0,0 +1,26 @@
-- name: CreateInboundHook :one
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, function_id, secret, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: ListInboundHooks :many
SELECT * FROM core.inbound_hooks
WHERE project_id = $1
ORDER BY name;
-- name: GetInboundHookByToken :one
SELECT * FROM core.inbound_hooks
WHERE token = $1;
-- name: GetInboundHook :one
SELECT * FROM core.inbound_hooks
WHERE id = $1 AND project_id = $2;
-- name: TouchInboundHook :exec
UPDATE core.inbound_hooks
SET last_received_at = NOW()
WHERE id = $1;
-- name: DeleteInboundHook :exec
DELETE FROM core.inbound_hooks
WHERE id = $1 AND project_id = $2;
+90
View File
@@ -0,0 +1,90 @@
-- name: ListScheduledJobs :many
SELECT * FROM core.scheduled_jobs
WHERE project_id = $1
ORDER BY created_at ASC;
-- name: GetScheduledJobByID :one
SELECT * FROM core.scheduled_jobs
WHERE id = $1;
-- name: CreateScheduledJob :one
INSERT INTO core.scheduled_jobs (
project_id,
name,
schedule,
url,
secret,
payload,
enabled,
next_run_at,
created_by_user_id,
function_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *;
-- name: UpdateScheduledJob :one
UPDATE core.scheduled_jobs
SET name = $3,
schedule = $4,
url = $5,
enabled = $6,
next_run_at = $7,
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END,
updated_at = NOW()
WHERE id = $1
AND project_id = $2
RETURNING *;
-- name: DeleteScheduledJob :one
DELETE FROM core.scheduled_jobs
WHERE id = $1
AND project_id = $2
RETURNING id;
-- name: ListDueScheduledJobs :many
SELECT * FROM core.scheduled_jobs
WHERE enabled
AND next_run_at IS NOT NULL
AND next_run_at <= $1;
-- name: MarkScheduledJobRan :one
UPDATE core.scheduled_jobs
SET last_run_at = $2,
last_status = $3,
next_run_at = $4,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: InsertScheduledJobRun :one
INSERT INTO core.scheduled_job_runs (job_id, triggered_by)
VALUES ($1, $2)
RETURNING *;
-- name: FinishScheduledJobRun :one
UPDATE core.scheduled_job_runs
SET status = $2,
status_code = $3,
error = $4,
duration_ms = $5,
finished_at = NOW()
WHERE id = $1
RETURNING *;
-- name: ListScheduledJobRuns :many
SELECT * FROM core.scheduled_job_runs
WHERE job_id = $1
ORDER BY created_at DESC
LIMIT $2;
-- name: PruneScheduledJobRuns :exec
DELETE FROM core.scheduled_job_runs r
WHERE r.job_id = $1
AND r.id NOT IN (
SELECT k.id FROM core.scheduled_job_runs k
WHERE k.job_id = $1
ORDER BY k.created_at DESC
LIMIT $2
);
@@ -147,3 +147,7 @@ WHERE p.organization_id = $1;
DELETE FROM core.organizations
WHERE id = $1
RETURNING *;
-- name: GetOrganizationByID :one
SELECT * FROM core.organizations
WHERE id = $1;
@@ -0,0 +1,36 @@
-- name: ListProjectSecrets :many
SELECT id, project_id, name, url, notes, created_at, updated_at
FROM core.project_secrets
WHERE project_id = $1
ORDER BY name ASC;
-- name: GetProjectSecret :one
SELECT * FROM core.project_secrets
WHERE project_id = $1
AND name = $2;
-- name: UpsertProjectSecret :one
INSERT INTO core.project_secrets (
project_id,
name,
ciphertext,
url,
notes
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, name) DO UPDATE SET
ciphertext = EXCLUDED.ciphertext,
url = EXCLUDED.url,
notes = EXCLUDED.notes,
updated_at = NOW()
RETURNING *;
-- name: DeleteProjectSecret :one
DELETE FROM core.project_secrets
WHERE project_id = $1
AND name = $2
RETURNING id;
-- name: ListProjectSecretValues :many
SELECT name, ciphertext
FROM core.project_secrets
WHERE project_id = $1;
+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
+4 -1
View File
@@ -11,7 +11,10 @@ WHERE id = $1;
SELECT * FROM core.webhooks
WHERE project_id = $1
AND enabled
AND (cardinality(events) = 0 OR $2::text = ANY(events));
AND (cardinality(events) = 0 OR EXISTS (
SELECT 1 FROM unnest(events) AS e
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
));
-- name: CreateWebhook :one
INSERT INTO core.webhooks (
+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=
+82 -44
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) {
@@ -78,9 +81,31 @@ func Bootstrap(ctx context.Context) (*App, error) {
}
}
store, err := storage.NewLocalStore(cfg.StorageRoot)
if err != nil {
return nil, err
var store storage.Store
if cfg.StorageDriver == "s3" {
s3Store, err := storage.NewS3Store(storage.S3Config{
Endpoint: cfg.S3Endpoint,
Region: cfg.S3Region,
Bucket: cfg.S3Bucket,
AccessKeyID: cfg.S3AccessKeyID,
SecretAccessKey: cfg.S3SecretAccessKey,
PublicEndpoint: cfg.S3PublicEndpoint,
Prefix: cfg.S3Prefix,
PathStyle: cfg.S3PathStyle,
})
if err != nil {
return nil, fmt.Errorf("s3 storage: %w", err)
}
if err := s3Store.EnsureBucket(context.Background()); err != nil {
return nil, err
}
store = s3Store
} else {
localStore, err := storage.NewLocalStore(cfg.StorageRoot)
if err != nil {
return nil, err
}
store = localStore
}
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
repo := repositories.NewCoreRepository(dbPool)
@@ -88,7 +113,14 @@ 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)
var fnRunner services.FunctionRunner
if cfg.FunctionsDriver == "docker" {
fnRunner = services.NewDockerRunner(cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
} else {
fnRunner = services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
}
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, fnRunner)
settings := services.NewSettingsService(repo, encryptor)
if cfg.Env == "production" {
gin.SetMode(gin.ReleaseMode)
@@ -123,10 +155,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 +191,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()
}
+67 -2
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
@@ -35,6 +36,26 @@ type Config struct {
SMTPSecure bool
EncryptionKey string
// Object storage: "local" (BACKEND_STORAGE_ROOT) or "s3".
StorageDriver string
S3Endpoint string
S3Region string
S3Bucket string
S3AccessKeyID string
S3SecretAccessKey string
S3PublicEndpoint string // presigned-URL host when it differs from Endpoint
S3Prefix string
S3PathStyle bool
// Functions runtime: driver "exec" (runtime binary on the backend host)
// or "docker" (ephemeral locked-down container), binary name/path for
// exec ("auto" resolves per-function runtime from PATH), timeout and
// output cap.
FunctionsDriver string
FunctionsRuntime string
FunctionsTimeout time.Duration
FunctionsMaxBytes int64
}
// devEncryptionKey is the built-in credential key for local development only.
@@ -63,6 +84,17 @@ func Load() (Config, error) {
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
StorageDriver: getenv("BACKEND_STORAGE_DRIVER", "local"),
S3Endpoint: os.Getenv("S3_ENDPOINT"),
S3Region: getenv("S3_REGION", "us-east-1"),
S3Bucket: os.Getenv("S3_BUCKET"),
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"),
S3Prefix: os.Getenv("S3_PREFIX"),
FunctionsDriver: getenv("FUNCTIONS_DRIVER", "exec"),
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
FunctionsMaxBytes: 65536,
}
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
@@ -95,6 +127,24 @@ func Load() (Config, error) {
}
cfg.SMTPSecure = smtpSecure
s3PathStyle, err := strconv.ParseBool(getenv("S3_PATH_STYLE", "true"))
if err != nil {
return Config{}, fmt.Errorf("parse S3_PATH_STYLE: %w", err)
}
cfg.S3PathStyle = s3PathStyle
fnTimeout, err := strconv.Atoi(getenv("FUNCTIONS_TIMEOUT_SECONDS", "30"))
if err != nil {
return Config{}, fmt.Errorf("parse FUNCTIONS_TIMEOUT_SECONDS: %w", err)
}
cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second
switch cfg.FunctionsDriver {
case "exec", "docker":
default:
return Config{}, fmt.Errorf("FUNCTIONS_DRIVER %q must be exec or docker", cfg.FunctionsDriver)
}
var missing []string
if cfg.DatabaseURL == "" {
missing = append(missing, "DATABASE_URL")
@@ -102,8 +152,23 @@ func Load() (Config, error) {
if cfg.JWTSecret == "" {
missing = append(missing, "JWT_SECRET")
}
if cfg.StorageRoot == "" {
missing = append(missing, "BACKEND_STORAGE_ROOT")
switch cfg.StorageDriver {
case "local":
if cfg.StorageRoot == "" {
missing = append(missing, "BACKEND_STORAGE_ROOT")
}
case "s3":
if cfg.S3Bucket == "" {
missing = append(missing, "S3_BUCKET")
}
if cfg.S3AccessKeyID == "" {
missing = append(missing, "S3_ACCESS_KEY_ID")
}
if cfg.S3SecretAccessKey == "" {
missing = append(missing, "S3_SECRET_ACCESS_KEY")
}
default:
return Config{}, fmt.Errorf("unknown BACKEND_STORAGE_DRIVER %q (want local or s3)", cfg.StorageDriver)
}
if cfg.AuthInternalBaseURL == "" {
missing = append(missing, "AUTH_INTERNAL_BASE_URL")
@@ -0,0 +1,290 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: alert_rules.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createAlertRule = `-- name: CreateAlertRule :one
INSERT INTO core.alert_rules (project_id, name, kind, config)
VALUES ($1, $2, $3, $4)
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
`
type CreateAlertRuleParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Config []byte `json:"config"`
}
func (q *Queries) CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) {
row := q.db.QueryRow(ctx, createAlertRule,
arg.ProjectID,
arg.Name,
arg.Kind,
arg.Config,
)
var i CoreAlertRule
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteAlertRule = `-- name: DeleteAlertRule :exec
DELETE FROM core.alert_rules
WHERE id = $1 AND project_id = $2
`
type DeleteAlertRuleParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error {
_, err := q.db.Exec(ctx, deleteAlertRule, arg.ID, arg.ProjectID)
return err
}
const getAlertRule = `-- name: GetAlertRule :one
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
WHERE id = $1 AND project_id = $2
`
type GetAlertRuleParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) {
row := q.db.QueryRow(ctx, getAlertRule, arg.ID, arg.ProjectID)
var i CoreAlertRule
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const lastHeartbeats = `-- name: LastHeartbeats :many
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
FROM core.components co
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
WHERE co.project_id = $1
GROUP BY co.name
`
type LastHeartbeatsRow struct {
ComponentName string `json:"component_name"`
LastBeat pgtype.Timestamptz `json:"last_beat"`
}
// Per-component most recent heartbeat — heartbeat_silence detection.
func (q *Queries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) {
rows, err := q.db.Query(ctx, lastHeartbeats, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []LastHeartbeatsRow
for rows.Next() {
var i LastHeartbeatsRow
if err := rows.Scan(&i.ComponentName, &i.LastBeat); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlertRules = `-- name: ListAlertRules :many
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
WHERE project_id = $1
ORDER BY name
`
func (q *Queries) ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) {
rows, err := q.db.Query(ctx, listAlertRules, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreAlertRule
for rows.Next() {
var i CoreAlertRule
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listEnabledAlertRules = `-- name: ListEnabledAlertRules :many
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
WHERE enabled
`
func (q *Queries) ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) {
rows, err := q.db.Query(ctx, listEnabledAlertRules)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreAlertRule
for rows.Next() {
var i CoreAlertRule
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const recentErrorCounts = `-- name: RecentErrorCounts :many
SELECT co.name AS component_name, count(*) AS errors
FROM core.events e
JOIN core.components co ON co.id = e.component_id
WHERE e.project_id = $1 AND e.type = 'error'
AND e.ts > now() - make_interval(secs => $2::bigint)
GROUP BY co.name
`
type RecentErrorCountsParams struct {
ProjectID uuid.UUID `json:"project_id"`
Column2 int64 `json:"column_2"`
}
type RecentErrorCountsRow struct {
ComponentName string `json:"component_name"`
Errors int64 `json:"errors"`
}
// Per-component error count inside a sliding window — error_spike detection.
func (q *Queries) RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) {
rows, err := q.db.Query(ctx, recentErrorCounts, arg.ProjectID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecentErrorCountsRow
for rows.Next() {
var i RecentErrorCountsRow
if err := rows.Scan(&i.ComponentName, &i.Errors); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setAlertRuleState = `-- name: SetAlertRuleState :exec
UPDATE core.alert_rules
SET state = $3, updated_at = NOW()
WHERE id = $1 AND project_id = $2
`
type SetAlertRuleStateParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
State []byte `json:"state"`
}
func (q *Queries) SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error {
_, err := q.db.Exec(ctx, setAlertRuleState, arg.ID, arg.ProjectID, arg.State)
return err
}
const updateAlertRule = `-- name: UpdateAlertRule :one
UPDATE core.alert_rules
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
`
type UpdateAlertRuleParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Config []byte `json:"config"`
Enabled bool `json:"enabled"`
}
func (q *Queries) UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) {
row := q.db.QueryRow(ctx, updateAlertRule,
arg.ID,
arg.ProjectID,
arg.Name,
arg.Config,
arg.Enabled,
)
var i CoreAlertRule
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -18,9 +18,10 @@ INSERT INTO core.api_keys (
name,
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,91 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: email_log.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const insertEmailLog = `-- name: InsertEmailLog :one
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, project_id, template, to_email, subject, status, error, created_at
`
type InsertEmailLogParams struct {
ProjectID pgtype.UUID `json:"project_id"`
Template string `json:"template"`
ToEmail string `json:"to_email"`
Subject string `json:"subject"`
Status string `json:"status"`
Error string `json:"error"`
}
func (q *Queries) InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) {
row := q.db.QueryRow(ctx, insertEmailLog,
arg.ProjectID,
arg.Template,
arg.ToEmail,
arg.Subject,
arg.Status,
arg.Error,
)
var i CoreEmailLog
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Template,
&i.ToEmail,
&i.Subject,
&i.Status,
&i.Error,
&i.CreatedAt,
)
return i, err
}
const listEmailLog = `-- name: ListEmailLog :many
SELECT id, project_id, template, to_email, subject, status, error, created_at FROM core.email_log
WHERE project_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListEmailLogParams struct {
ProjectID pgtype.UUID `json:"project_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) {
rows, err := q.db.Query(ctx, listEmailLog, arg.ProjectID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreEmailLog
for rows.Next() {
var i CoreEmailLog
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Template,
&i.ToEmail,
&i.Subject,
&i.Status,
&i.Error,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -0,0 +1,313 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: functions.sql
package db
import (
"context"
"github.com/google/uuid"
)
const createFunction = `-- name: CreateFunction :one
INSERT INTO core.functions (project_id, name, code, runtime, event_pattern)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
`
type CreateFunctionParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Code string `json:"code"`
Runtime string `json:"runtime"`
EventPattern string `json:"event_pattern"`
}
func (q *Queries) CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error) {
row := q.db.QueryRow(ctx, createFunction,
arg.ProjectID,
arg.Name,
arg.Code,
arg.Runtime,
arg.EventPattern,
)
var i CoreFunction
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
&i.EventPattern,
)
return i, err
}
const deleteFunction = `-- name: DeleteFunction :exec
DELETE FROM core.functions WHERE id = $1 AND project_id = $2
`
type DeleteFunctionParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error {
_, err := q.db.Exec(ctx, deleteFunction, arg.ID, arg.ProjectID)
return err
}
const getFunction = `-- name: GetFunction :one
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE id = $1
`
func (q *Queries) GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error) {
row := q.db.QueryRow(ctx, getFunction, id)
var i CoreFunction
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
&i.EventPattern,
)
return i, err
}
const getFunctionByName = `-- name: GetFunctionByName :one
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE project_id = $1 AND name = $2
`
type GetFunctionByNameParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error) {
row := q.db.QueryRow(ctx, getFunctionByName, arg.ProjectID, arg.Name)
var i CoreFunction
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
&i.EventPattern,
)
return i, err
}
const insertFunctionRun = `-- name: InsertFunctionRun :one
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at
`
type InsertFunctionRunParams struct {
FunctionID uuid.UUID `json:"function_id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
ExitCode *int32 `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
DurationMs int32 `json:"duration_ms"`
}
func (q *Queries) InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error) {
row := q.db.QueryRow(ctx, insertFunctionRun,
arg.FunctionID,
arg.Trigger,
arg.Status,
arg.ExitCode,
arg.Stdout,
arg.Stderr,
arg.DurationMs,
)
var i CoreFunctionRun
err := row.Scan(
&i.ID,
&i.FunctionID,
&i.Trigger,
&i.Status,
&i.ExitCode,
&i.Stdout,
&i.Stderr,
&i.DurationMs,
&i.CreatedAt,
)
return i, err
}
const listFunctionRuns = `-- name: ListFunctionRuns :many
SELECT id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at FROM core.function_runs
WHERE function_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListFunctionRunsParams struct {
FunctionID uuid.UUID `json:"function_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error) {
rows, err := q.db.Query(ctx, listFunctionRuns, arg.FunctionID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreFunctionRun
for rows.Next() {
var i CoreFunctionRun
if err := rows.Scan(
&i.ID,
&i.FunctionID,
&i.Trigger,
&i.Status,
&i.ExitCode,
&i.Stdout,
&i.Stderr,
&i.DurationMs,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listFunctions = `-- name: ListFunctions :many
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
WHERE project_id = $1
ORDER BY name
`
func (q *Queries) ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error) {
rows, err := q.db.Query(ctx, listFunctions, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreFunction
for rows.Next() {
var i CoreFunction
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
&i.EventPattern,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listFunctionsForEvent = `-- name: ListFunctionsForEvent :many
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
WHERE project_id = $1
AND enabled = TRUE
AND event_pattern <> ''
AND ($2 LIKE REPLACE(event_pattern, '*', '%') OR event_pattern = '*')
`
type ListFunctionsForEventParams struct {
ProjectID uuid.UUID `json:"project_id"`
EventPattern string `json:"event_pattern"`
}
func (q *Queries) ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error) {
rows, err := q.db.Query(ctx, listFunctionsForEvent, arg.ProjectID, arg.EventPattern)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreFunction
for rows.Next() {
var i CoreFunction
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
&i.EventPattern,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateFunction = `-- name: UpdateFunction :one
UPDATE core.functions
SET code = COALESCE($3, code),
enabled = COALESCE($4, enabled),
event_pattern = COALESCE($5, event_pattern),
updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
`
type UpdateFunctionParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Code *string `json:"code"`
Enabled *bool `json:"enabled"`
EventPattern *string `json:"event_pattern"`
}
func (q *Queries) UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error) {
row := q.db.QueryRow(ctx, updateFunction,
arg.ID,
arg.ProjectID,
arg.Code,
arg.Enabled,
arg.EventPattern,
)
var i CoreFunction
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
&i.EventPattern,
)
return i, err
}
@@ -0,0 +1,179 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: inbound_hooks.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createInboundHook = `-- name: CreateInboundHook :one
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, function_id, secret, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id
`
type CreateInboundHookParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Token string `json:"token"`
Mode string `json:"mode"`
JobID pgtype.UUID `json:"job_id"`
FunctionID pgtype.UUID `json:"function_id"`
Secret []byte `json:"secret"`
Enabled bool `json:"enabled"`
}
func (q *Queries) CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error) {
row := q.db.QueryRow(ctx, createInboundHook,
arg.ProjectID,
arg.Name,
arg.Token,
arg.Mode,
arg.JobID,
arg.FunctionID,
arg.Secret,
arg.Enabled,
)
var i CoreInboundHook
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Token,
&i.Mode,
&i.JobID,
&i.Secret,
&i.Enabled,
&i.LastReceivedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
)
return i, err
}
const deleteInboundHook = `-- name: DeleteInboundHook :exec
DELETE FROM core.inbound_hooks
WHERE id = $1 AND project_id = $2
`
type DeleteInboundHookParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error {
_, err := q.db.Exec(ctx, deleteInboundHook, arg.ID, arg.ProjectID)
return err
}
const getInboundHook = `-- name: GetInboundHook :one
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
WHERE id = $1 AND project_id = $2
`
type GetInboundHookParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error) {
row := q.db.QueryRow(ctx, getInboundHook, arg.ID, arg.ProjectID)
var i CoreInboundHook
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Token,
&i.Mode,
&i.JobID,
&i.Secret,
&i.Enabled,
&i.LastReceivedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
)
return i, err
}
const getInboundHookByToken = `-- name: GetInboundHookByToken :one
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
WHERE token = $1
`
func (q *Queries) GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error) {
row := q.db.QueryRow(ctx, getInboundHookByToken, token)
var i CoreInboundHook
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Token,
&i.Mode,
&i.JobID,
&i.Secret,
&i.Enabled,
&i.LastReceivedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
)
return i, err
}
const listInboundHooks = `-- name: ListInboundHooks :many
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
WHERE project_id = $1
ORDER BY name
`
func (q *Queries) ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error) {
rows, err := q.db.Query(ctx, listInboundHooks, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreInboundHook
for rows.Next() {
var i CoreInboundHook
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Token,
&i.Mode,
&i.JobID,
&i.Secret,
&i.Enabled,
&i.LastReceivedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const touchInboundHook = `-- name: TouchInboundHook :exec
UPDATE core.inbound_hooks
SET last_received_at = NOW()
WHERE id = $1
`
func (q *Queries) TouchInboundHook(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, touchInboundHook, id)
return err
}
@@ -0,0 +1,455 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: jobs.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createScheduledJob = `-- name: CreateScheduledJob :one
INSERT INTO core.scheduled_jobs (
project_id,
name,
schedule,
url,
secret,
payload,
enabled,
next_run_at,
created_by_user_id,
function_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
`
type CreateScheduledJobParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Url string `json:"url"`
Secret []byte `json:"secret"`
Payload []byte `json:"payload"`
Enabled bool `json:"enabled"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
FunctionID pgtype.UUID `json:"function_id"`
}
func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, createScheduledJob,
arg.ProjectID,
arg.Name,
arg.Schedule,
arg.Url,
arg.Secret,
arg.Payload,
arg.Enabled,
arg.NextRunAt,
arg.CreatedByUserID,
arg.FunctionID,
)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
)
return i, err
}
const deleteScheduledJob = `-- name: DeleteScheduledJob :one
DELETE FROM core.scheduled_jobs
WHERE id = $1
AND project_id = $2
RETURNING id
`
type DeleteScheduledJobParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error) {
row := q.db.QueryRow(ctx, deleteScheduledJob, arg.ID, arg.ProjectID)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const finishScheduledJobRun = `-- name: FinishScheduledJobRun :one
UPDATE core.scheduled_job_runs
SET status = $2,
status_code = $3,
error = $4,
duration_ms = $5,
finished_at = NOW()
WHERE id = $1
RETURNING id, job_id, status, triggered_by, status_code, error, duration_ms, created_at, finished_at
`
type FinishScheduledJobRunParams struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
StatusCode *int32 `json:"status_code"`
Error string `json:"error"`
DurationMs *int32 `json:"duration_ms"`
}
func (q *Queries) FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error) {
row := q.db.QueryRow(ctx, finishScheduledJobRun,
arg.ID,
arg.Status,
arg.StatusCode,
arg.Error,
arg.DurationMs,
)
var i CoreScheduledJobRun
err := row.Scan(
&i.ID,
&i.JobID,
&i.Status,
&i.TriggeredBy,
&i.StatusCode,
&i.Error,
&i.DurationMs,
&i.CreatedAt,
&i.FinishedAt,
)
return i, err
}
const getScheduledJobByID = `-- name: GetScheduledJobByID :one
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
WHERE id = $1
`
func (q *Queries) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, getScheduledJobByID, id)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
)
return i, err
}
const insertScheduledJobRun = `-- name: InsertScheduledJobRun :one
INSERT INTO core.scheduled_job_runs (job_id, triggered_by)
VALUES ($1, $2)
RETURNING id, job_id, status, triggered_by, status_code, error, duration_ms, created_at, finished_at
`
type InsertScheduledJobRunParams struct {
JobID uuid.UUID `json:"job_id"`
TriggeredBy string `json:"triggered_by"`
}
func (q *Queries) InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error) {
row := q.db.QueryRow(ctx, insertScheduledJobRun, arg.JobID, arg.TriggeredBy)
var i CoreScheduledJobRun
err := row.Scan(
&i.ID,
&i.JobID,
&i.Status,
&i.TriggeredBy,
&i.StatusCode,
&i.Error,
&i.DurationMs,
&i.CreatedAt,
&i.FinishedAt,
)
return i, err
}
const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
WHERE enabled
AND next_run_at IS NOT NULL
AND next_run_at <= $1
`
func (q *Queries) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error) {
rows, err := q.db.Query(ctx, listDueScheduledJobs, nextRunAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreScheduledJob
for rows.Next() {
var i CoreScheduledJob
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listScheduledJobRuns = `-- name: ListScheduledJobRuns :many
SELECT id, job_id, status, triggered_by, status_code, error, duration_ms, created_at, finished_at FROM core.scheduled_job_runs
WHERE job_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListScheduledJobRunsParams struct {
JobID uuid.UUID `json:"job_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error) {
rows, err := q.db.Query(ctx, listScheduledJobRuns, arg.JobID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreScheduledJobRun
for rows.Next() {
var i CoreScheduledJobRun
if err := rows.Scan(
&i.ID,
&i.JobID,
&i.Status,
&i.TriggeredBy,
&i.StatusCode,
&i.Error,
&i.DurationMs,
&i.CreatedAt,
&i.FinishedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listScheduledJobs = `-- name: ListScheduledJobs :many
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
WHERE project_id = $1
ORDER BY created_at ASC
`
func (q *Queries) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error) {
rows, err := q.db.Query(ctx, listScheduledJobs, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreScheduledJob
for rows.Next() {
var i CoreScheduledJob
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markScheduledJobRan = `-- name: MarkScheduledJobRan :one
UPDATE core.scheduled_jobs
SET last_run_at = $2,
last_status = $3,
next_run_at = $4,
updated_at = NOW()
WHERE id = $1
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
`
type MarkScheduledJobRanParams struct {
ID uuid.UUID `json:"id"`
LastRunAt pgtype.Timestamptz `json:"last_run_at"`
LastStatus string `json:"last_status"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
}
func (q *Queries) MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobRanParams) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, markScheduledJobRan,
arg.ID,
arg.LastRunAt,
arg.LastStatus,
arg.NextRunAt,
)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
)
return i, err
}
const pruneScheduledJobRuns = `-- name: PruneScheduledJobRuns :exec
DELETE FROM core.scheduled_job_runs r
WHERE r.job_id = $1
AND r.id NOT IN (
SELECT k.id FROM core.scheduled_job_runs k
WHERE k.job_id = $1
ORDER BY k.created_at DESC
LIMIT $2
)
`
type PruneScheduledJobRunsParams struct {
JobID uuid.UUID `json:"job_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error {
_, err := q.db.Exec(ctx, pruneScheduledJobRuns, arg.JobID, arg.Limit)
return err
}
const updateScheduledJob = `-- name: UpdateScheduledJob :one
UPDATE core.scheduled_jobs
SET name = $3,
schedule = $4,
url = $5,
enabled = $6,
next_run_at = $7,
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END,
updated_at = NOW()
WHERE id = $1
AND project_id = $2
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
`
type UpdateScheduledJobParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Url string `json:"url"`
Enabled bool `json:"enabled"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
Column8 bool `json:"column_8"`
Secret []byte `json:"secret"`
Column10 bool `json:"column_10"`
Payload []byte `json:"payload"`
Column12 bool `json:"column_12"`
FunctionID pgtype.UUID `json:"function_id"`
}
func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) {
row := q.db.QueryRow(ctx, updateScheduledJob,
arg.ID,
arg.ProjectID,
arg.Name,
arg.Schedule,
arg.Url,
arg.Enabled,
arg.NextRunAt,
arg.Column8,
arg.Secret,
arg.Column10,
arg.Payload,
arg.Column12,
arg.FunctionID,
)
var i CoreScheduledJob
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Schedule,
&i.Url,
&i.Secret,
&i.Payload,
&i.Enabled,
&i.LastRunAt,
&i.LastStatus,
&i.NextRunAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
)
return i, err
}
+121 -6
View File
@@ -140,6 +140,18 @@ func (ns NullCoreProjectRole) Value() (driver.Value, error) {
return string(ns.CoreProjectRole), nil
}
type CoreAlertRule struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Config []byte `json:"config"`
Enabled bool `json:"enabled"`
State []byte `json:"state"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreApiKey struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
@@ -150,6 +162,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 {
@@ -230,6 +243,17 @@ type CoreDocument struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreEmailLog struct {
ID uuid.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
Template string `json:"template"`
ToEmail string `json:"to_email"`
Subject string `json:"subject"`
Status string `json:"status"`
Error string `json:"error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreEvent struct {
ID int64 `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
@@ -242,6 +266,45 @@ type CoreEvent struct {
Ts pgtype.Timestamptz `json:"ts"`
}
type CoreFunction struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Code string `json:"code"`
Runtime string `json:"runtime"`
Enabled bool `json:"enabled"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EventPattern string `json:"event_pattern"`
}
type CoreFunctionRun struct {
ID uuid.UUID `json:"id"`
FunctionID uuid.UUID `json:"function_id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
ExitCode *int32 `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
DurationMs int32 `json:"duration_ms"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreInboundHook struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Token string `json:"token"`
Mode string `json:"mode"`
JobID pgtype.UUID `json:"job_id"`
Secret []byte `json:"secret"`
Enabled bool `json:"enabled"`
LastReceivedAt pgtype.Timestamptz `json:"last_received_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
FunctionID pgtype.UUID `json:"function_id"`
}
type CoreIntegration struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
@@ -273,12 +336,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 +369,55 @@ type CoreProjectMember struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreProjectSecret struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Ciphertext []byte `json:"ciphertext"`
Url string `json:"url"`
Notes string `json:"notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreScheduledJob struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
Url string `json:"url"`
Secret []byte `json:"secret"`
Payload []byte `json:"payload"`
Enabled bool `json:"enabled"`
LastRunAt pgtype.Timestamptz `json:"last_run_at"`
LastStatus string `json:"last_status"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
FunctionID pgtype.UUID `json:"function_id"`
}
type CoreScheduledJobRun struct {
ID uuid.UUID `json:"id"`
JobID uuid.UUID `json:"job_id"`
Status string `json:"status"`
TriggeredBy string `json:"triggered_by"`
StatusCode *int32 `json:"status_code"`
Error string `json:"error"`
DurationMs *int32 `json:"duration_ms"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
FinishedAt pgtype.Timestamptz `json:"finished_at"`
}
type CoreSetting struct {
Key string `json:"key"`
Value []byte `json:"value"`
Secret bool `json:"secret"`
UpdatedBy *string `json:"updated_by"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreUser struct {
ID uuid.UUID `json:"id"`
AuthSubject string `json:"auth_subject"`
@@ -241,6 +241,23 @@ func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string
return i, err
}
const getOrganizationByID = `-- name: GetOrganizationByID :one
SELECT id, slug, name, created_at FROM core.organizations
WHERE id = $1
`
func (q *Queries) GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) {
row := q.db.QueryRow(ctx, getOrganizationByID, id)
var i CoreOrganization
err := row.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
SELECT
om.id, om.organization_id, om.user_id, om.role, om.created_at,
@@ -0,0 +1,181 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: project_secrets.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteProjectSecret = `-- name: DeleteProjectSecret :one
DELETE FROM core.project_secrets
WHERE project_id = $1
AND name = $2
RETURNING id
`
type DeleteProjectSecretParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error) {
row := q.db.QueryRow(ctx, deleteProjectSecret, arg.ProjectID, arg.Name)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const getProjectSecret = `-- name: GetProjectSecret :one
SELECT id, project_id, name, ciphertext, url, notes, created_at, updated_at FROM core.project_secrets
WHERE project_id = $1
AND name = $2
`
type GetProjectSecretParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error) {
row := q.db.QueryRow(ctx, getProjectSecret, arg.ProjectID, arg.Name)
var i CoreProjectSecret
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Ciphertext,
&i.Url,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listProjectSecretValues = `-- name: ListProjectSecretValues :many
SELECT name, ciphertext
FROM core.project_secrets
WHERE project_id = $1
`
type ListProjectSecretValuesRow struct {
Name string `json:"name"`
Ciphertext []byte `json:"ciphertext"`
}
func (q *Queries) ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error) {
rows, err := q.db.Query(ctx, listProjectSecretValues, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProjectSecretValuesRow
for rows.Next() {
var i ListProjectSecretValuesRow
if err := rows.Scan(&i.Name, &i.Ciphertext); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectSecrets = `-- name: ListProjectSecrets :many
SELECT id, project_id, name, url, notes, created_at, updated_at
FROM core.project_secrets
WHERE project_id = $1
ORDER BY name ASC
`
type ListProjectSecretsRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Url string `json:"url"`
Notes string `json:"notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error) {
rows, err := q.db.Query(ctx, listProjectSecrets, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProjectSecretsRow
for rows.Next() {
var i ListProjectSecretsRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Url,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertProjectSecret = `-- name: UpsertProjectSecret :one
INSERT INTO core.project_secrets (
project_id,
name,
ciphertext,
url,
notes
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, name) DO UPDATE SET
ciphertext = EXCLUDED.ciphertext,
url = EXCLUDED.url,
notes = EXCLUDED.notes,
updated_at = NOW()
RETURNING id, project_id, name, ciphertext, url, notes, created_at, updated_at
`
type UpsertProjectSecretParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Ciphertext []byte `json:"ciphertext"`
Url string `json:"url"`
Notes string `json:"notes"`
}
func (q *Queries) UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error) {
row := q.db.QueryRow(ctx, upsertProjectSecret,
arg.ProjectID,
arg.Name,
arg.Ciphertext,
arg.Url,
arg.Notes,
)
var i CoreProjectSecret
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Ciphertext,
&i.Url,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -59,7 +59,7 @@ INSERT INTO core.projects (
name,
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 {
@@ -22,32 +23,44 @@ type Querier interface {
CountOrganizations(ctx context.Context) (int64, error)
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error)
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error)
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
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)
DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
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)
DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error)
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
DeleteSetting(ctx context.Context, key string) error
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error)
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)
GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error)
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
@@ -56,21 +69,35 @@ type Querier interface {
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error)
GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error)
GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error)
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error)
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
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)
GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error)
GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error)
GetSetting(ctx context.Context, key string) (GetSettingRow, error)
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
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)
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error)
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error)
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
// Per-component most recent heartbeat — heartbeat_silence detection.
LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error)
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error)
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
@@ -79,38 +106,70 @@ type Querier interface {
ListComponents(ctx context.Context, projectID uuid.UUID) ([]CoreComponent, error)
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)
ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error)
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error)
ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error)
ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error)
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error)
ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error)
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
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
// Per-component error count inside a sliding window — error_spike detection.
RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error)
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error
SweepExpiredAuditLogs(ctx context.Context) (int64, error)
// Per-project retention sweeps. A retention of 0 days disables the sweep for
// that stream. Pending webhook deliveries are never swept — they may still be
// retrying.
SweepExpiredEvents(ctx context.Context) (int64, error)
// Org-scoped audit rows (project_id NULL) have no project retention — sweep
// them on a fixed 365-day floor so they cannot grow unbounded.
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
TouchAPIKey(ctx context.Context, id uuid.UUID) error
TouchInboundHook(ctx context.Context, id uuid.UUID) error
UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error)
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error)
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
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)
UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, 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) {
@@ -291,7 +291,10 @@ const listWebhooksForEvent = `-- name: ListWebhooksForEvent :many
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
WHERE project_id = $1
AND enabled
AND (cardinality(events) = 0 OR $2::text = ANY(events))
AND (cardinality(events) = 0 OR EXISTS (
SELECT 1 FROM unnest(events) AS e
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
))
`
type ListWebhooksForEventParams struct {
+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{}
+97
View File
@@ -0,0 +1,97 @@
package handlers
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
func (h *HTTPHandler) alertError(c *gin.Context, err error) {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "alert rule not found")
return
}
msg := err.Error()
if strings.Contains(msg, "invalid") || strings.Contains(msg, "must be") || strings.Contains(msg, "already exists") || strings.Contains(msg, "too long") {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
return
}
h.handleError(c, err)
}
func (h *HTTPHandler) listAlertRules(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListAlertRules(c.Request.Context(), actor, projectID)
if err != nil {
h.alertError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) createAlertRule(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.UpsertAlertRuleInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateAlertRule(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.alertError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) updateAlertRule(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
ruleID, err := uuid.Parse(c.Param("ruleID"))
if err != nil {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id")
return
}
var body services.UpsertAlertRuleInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.UpdateAlertRule(c.Request.Context(), actor, projectID, ruleID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.alertError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) deleteAlertRule(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
ruleID, err := uuid.Parse(c.Param("ruleID"))
if err != nil {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id")
return
}
if err := h.Platform.DeleteAlertRule(c.Request.Context(), actor, projectID, ruleID, middleware.RequestIDFromContext(c)); err != nil {
h.alertError(c, err)
return
}
c.Status(http.StatusNoContent)
}
@@ -204,6 +204,23 @@ func (h *HTTPHandler) dbxRedis(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
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)
+44
View File
@@ -0,0 +1,44 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
)
// publishEvent accepts a client-authored domain event (custom.* namespace)
// and fans it out through the standard publishEvent path — realtime
// subscribers, matching webhooks, and event_pattern functions all fire.
func (h *HTTPHandler) publishEvent(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body struct {
Type string `json:"type" validate:"required"`
Data map[string]any `json:"data"`
}
if !h.bindAndValidate(c, &body) {
return
}
if err := h.Platform.PublishProjectEvent(c.Request.Context(), actor, projectID, body.Type, body.Data); err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"published": body.Type})
}
// realtimePresence returns the count of clients currently subscribed to the
// project's realtime stream.
func (h *HTTPHandler) realtimePresence(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
n, err := h.Platform.Presence(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"online": n})
}
+146
View File
@@ -0,0 +1,146 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
func (h *HTTPHandler) listFunctions(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
items, err := h.Platform.ListFunctions(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *HTTPHandler) createFunction(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.CreateFunctionInput
if !h.bindAndValidate(c, &body) {
return
}
fn, err := h.Platform.CreateFunction(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, fn)
}
func (h *HTTPHandler) getFunction(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
fn, code, err := h.Platform.GetFunctionCode(c.Request.Context(), actor, functionID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"function": fn, "code": code})
}
func (h *HTTPHandler) updateFunction(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
var body services.UpdateFunctionInput
if !h.bindAndValidate(c, &body) {
return
}
fn, err := h.Platform.UpdateFunction(c.Request.Context(), actor, functionID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, fn)
}
func (h *HTTPHandler) deleteFunction(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
if err := h.Platform.DeleteFunction(c.Request.Context(), actor, functionID, middleware.RequestIDFromContext(c)); err != nil {
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) invokeFunction(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
var body struct {
Payload json.RawMessage `json:"payload"`
}
// An empty body is a valid invoke — defaults to {}.
if c.Request.Body != nil && c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&body); err != nil {
apperrors.Abort(c, http.StatusBadRequest, "invalid_json", err.Error())
return
}
}
run, err := h.Platform.InvokeFunction(c.Request.Context(), actor, functionID, body.Payload, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
status := http.StatusOK
if run.Status != "success" {
status = http.StatusBadGateway
}
c.JSON(status, run)
}
func (h *HTTPHandler) listFunctionRuns(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
limit, _ := strconv.ParseInt(c.DefaultQuery("limit", "50"), 10, 64)
runs, err := h.Platform.ListFunctionRuns(c.Request.Context(), actor, functionID, limit)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": runs})
}
+152 -6
View File
@@ -2,6 +2,7 @@ package handlers
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
@@ -11,7 +12,9 @@ import (
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/observability"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
@@ -20,6 +23,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 +33,15 @@ 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("/context", h.actorContext)
api.GET("/instance/public", h.instancePublic)
api.GET("/instance/settings", h.listInstanceSettings)
api.PUT("/instance/settings/:key", h.updateInstanceSetting)
api.DELETE("/instance/settings/:key", h.deleteInstanceSetting)
api.POST("/bootstrap", h.bootstrap)
api.GET("/organizations", h.organizations)
api.POST("/organizations", h.createOrganization)
@@ -72,7 +82,20 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
api.OPTIONS("/ingest", h.ingestOptions)
api.POST("/ingest", h.ingest)
api.POST("/hooks/:token", h.receiveInboundHook)
api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks)
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
api.GET("/projects/:projectID/emails", h.listEmailLog)
api.GET("/projects/:projectID/functions", h.listFunctions)
api.POST("/projects/:projectID/functions", h.createFunction)
api.GET("/projects/:projectID/functions/:functionID", h.getFunction)
api.PUT("/projects/:projectID/functions/:functionID", h.updateFunction)
api.DELETE("/projects/:projectID/functions/:functionID", h.deleteFunction)
api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction)
api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns)
api.GET("/projects/:projectID/events", h.listEvents)
api.POST("/projects/:projectID/events", h.publishEvent)
api.GET("/projects/:projectID/issues", h.listIssues)
api.GET("/projects/:projectID/components", h.listComponents)
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
@@ -91,6 +114,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.GET("/projects/:projectID/db-connections/:connectionID/foreign-keys", h.dbxForeignKeys)
api.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,9 +127,27 @@ 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/secrets", h.listProjectSecrets)
api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret)
api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret)
api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret)
api.GET("/projects/:projectID/alerts", h.listAlertRules)
api.POST("/projects/:projectID/alerts", h.createAlertRule)
api.PUT("/projects/:projectID/alerts/:ruleID", h.updateAlertRule)
api.DELETE("/projects/:projectID/alerts/:ruleID", h.deleteAlertRule)
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
api.GET("/projects/:projectID/realtime/presence", h.realtimePresence)
api.GET("/buckets/:bucketID/objects", h.listObjects)
api.POST("/buckets/:bucketID/objects", h.uploadObject)
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
api.POST("/buckets/:bucketID/object-presigns", h.presignObject)
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
@@ -123,6 +165,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 {
@@ -136,6 +184,19 @@ func (h *HTTPHandler) me(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) actorContext(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
result, err := h.Platform.ActorContext(c.Request.Context(), actor)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) bootstrap(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
@@ -849,6 +910,43 @@ func (h *HTTPHandler) updateObject(c *gin.Context) {
c.Status(http.StatusNoContent)
}
type presignObjectRequest struct {
Key string `json:"key" validate:"required"`
Op string `json:"op" validate:"omitempty,oneof=download upload"`
TTLSeconds int `json:"ttl_seconds" validate:"omitempty,min=60,max=3600"`
}
// presignObject returns a short-lived URL clients call against storage
// directly — keeps large transfers off the backend. S3 driver only.
func (h *HTTPHandler) presignObject(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
bucketID, ok := parseUUIDParam(c, "bucketID")
if !ok {
return
}
var body presignObjectRequest
if !h.bindAndValidate(c, &body) {
return
}
out, err := h.Platform.PresignObject(c.Request.Context(), actor, bucketID, strings.TrimSpace(body.Key), body.Op, body.TTLSeconds)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
return
}
if errors.Is(err, services.ErrPresignUnsupported) {
apperrors.Abort(c, http.StatusBadRequest, "presign_unsupported", err.Error())
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *HTTPHandler) deleteObject(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
@@ -1033,12 +1131,19 @@ func (h *HTTPHandler) listDocuments(c *gin.Context) {
return
}
actor, _ := middleware.ActorFromContext(c)
result, err := h.Platform.ListDocuments(c.Request.Context(), actor, collectionID, limit, offset)
if err != nil {
h.handleError(c, err)
return
var result []db.CoreDocument
var total int64
var err error
// PostgREST-style filter/order switches to the dynamic-query path; the
// plain list keeps using the sqlc queries it always has.
if filter := c.Query("filter"); filter != "" || c.Query("order") != "" {
result, total, err = h.Platform.SearchDocuments(c.Request.Context(), actor, collectionID, filter, c.Query("order"), limit, offset)
} else {
result, err = h.Platform.ListDocuments(c.Request.Context(), actor, collectionID, limit, offset)
if err == nil {
total, err = h.Platform.CountDocuments(c.Request.Context(), actor, collectionID)
}
}
total, err := h.Platform.CountDocuments(c.Request.Context(), actor, collectionID)
if err != nil {
h.handleError(c, err)
return
@@ -1175,17 +1280,58 @@ func (h *HTTPHandler) bindAndValidate(c *gin.Context, target any) bool {
return false
}
if err := h.Validate.Struct(target); err != nil {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", err.Error())
var valErrs validator.ValidationErrors
if errors.As(err, &valErrs) && len(valErrs) > 0 {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", formatFieldError(valErrs[0]))
} else {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", "invalid request body")
}
return false
}
return true
}
func formatFieldError(f validator.FieldError) string {
if f.Tag() == "required" {
return fmt.Sprintf("%s is required", strings.ToLower(f.Field()))
}
return fmt.Sprintf("%s failed %s validation", strings.ToLower(f.Field()), f.Tag())
}
func (h *HTTPHandler) handleError(c *gin.Context, err error) {
switch {
case errors.Is(err, io.EOF):
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
case errors.Is(err, pgx.ErrNoRows):
apperrors.Abort(c, http.StatusNotFound, "not_found", "resource not found")
default:
var inputErr *services.InputError
if errors.As(err, &inputErr) {
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", inputErr.Error())
return
}
var valErrs validator.ValidationErrors
if errors.As(err, &valErrs) && len(valErrs) > 0 {
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", formatFieldError(valErrs[0]))
return
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
// Structured codes — never leak raw SQLSTATE text or constraint names.
switch pgErr.Code {
case "23505":
apperrors.Abort(c, http.StatusConflict, "conflict", "resource already exists")
case "23503":
apperrors.Abort(c, http.StatusConflict, "conflict", "referenced resource does not exist")
case "22001", "54000":
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "value too large")
case "22021":
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "input is not valid UTF-8")
default:
apperrors.Abort(c, http.StatusInternalServerError, "request_failed", "database error")
}
return
}
status := http.StatusInternalServerError
message := strings.ToLower(err.Error())
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
@@ -4,9 +4,14 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
@@ -104,4 +109,46 @@ func TestHandleErrorStatusMapping(t *testing.T) {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
t.Run("maps missing rows to not found", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, pgx.ErrNoRows)
if recorder.Code != http.StatusNotFound {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
t.Run("maps InputError to bad request with message", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, services.NewInputError("webhook url must be https"))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), "webhook url must be https") {
t.Fatalf("expected reason in body, got %s", recorder.Body.String())
}
})
t.Run("maps unique violation to conflict without leaking", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint \"projects_slug_key\""})
if recorder.Code != http.StatusConflict {
t.Fatalf("unexpected status: %d", recorder.Code)
}
if strings.Contains(recorder.Body.String(), "projects_slug_key") || strings.Contains(recorder.Body.String(), "23505") {
t.Fatalf("raw constraint leaked: %s", recorder.Body.String())
}
})
t.Run("maps unknown pg error to internal error", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, &pgconn.PgError{Code: "XX000", Message: "internal"})
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
}
+117
View File
@@ -0,0 +1,117 @@
package handlers
import (
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
const maxInboundBody = 256 * 1024 // external POSTs capped at 256 KiB
func (h *HTTPHandler) inboundError(c *gin.Context, err error) {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "hook not found")
return
}
msg := err.Error()
switch {
case strings.Contains(msg, "invalid signature"):
apperrors.Abort(c, http.StatusUnauthorized, "unauthorized", msg)
case strings.Contains(msg, "invalid") || strings.Contains(msg, "already exists") || strings.Contains(msg, "disabled") || strings.Contains(msg, "requires") || strings.Contains(msg, "not found in this project"):
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
default:
h.handleError(c, err)
}
}
func (h *HTTPHandler) listInboundHooks(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListInboundHooks(c.Request.Context(), actor, projectID)
if err != nil {
h.inboundError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) createInboundHook(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.CreateInboundHookInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateInboundHook(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.inboundError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) deleteInboundHook(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
hookID, err := uuid.Parse(c.Param("hookID"))
if err != nil {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid hook id")
return
}
if err := h.Platform.DeleteInboundHook(c.Request.Context(), actor, projectID, hookID, middleware.RequestIDFromContext(c)); err != nil {
h.inboundError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) listEmailLog(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var limit int64
if raw := c.Query("limit"); raw != "" {
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
limit = n
}
}
result, err := h.Platform.ListEmailLog(c.Request.Context(), actor, projectID, limit)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
// receiveInboundHook is the public ingest endpoint — the URL token is the
// credential, so no actor is required.
func (h *HTTPHandler) receiveInboundHook(c *gin.Context) {
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxInboundBody))
if err != nil {
apperrors.Abort(c, http.StatusRequestEntityTooLarge, "too_large", "body exceeds 256 KiB")
return
}
result, err := h.Platform.ReceiveInboundHook(c.Request.Context(), c.Param("token"), c.GetHeader("X-Primora-Signature"), body)
if err != nil {
h.inboundError(c, err)
return
}
c.JSON(http.StatusAccepted, result)
}
+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
}
})
}
+87
View File
@@ -0,0 +1,87 @@
package handlers
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
func (h *HTTPHandler) secretError(c *gin.Context, err error) {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "secret not found")
return
}
msg := err.Error()
if strings.Contains(msg, "invalid secret name") {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
return
}
if strings.Contains(msg, "encryption is not configured") {
apperrors.Abort(c, http.StatusServiceUnavailable, "unavailable", msg)
return
}
h.handleError(c, err)
}
func (h *HTTPHandler) listProjectSecrets(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListProjectSecrets(c.Request.Context(), actor, projectID)
if err != nil {
h.secretError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) setProjectSecret(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.SetProjectSecretInput
if !h.bindAndValidate(c, &body) {
return
}
body.Name = c.Param("name")
result, err := h.Platform.SetProjectSecret(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.secretError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) deleteProjectSecret(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
if err := h.Platform.DeleteProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c)); err != nil {
h.secretError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) revealProjectSecret(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.RevealProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c))
if err != nil {
h.secretError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
+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())
}
@@ -27,6 +27,12 @@ func (r *CoreRepository) Queries() *db.Queries {
return r.queries
}
// Pool exposes the pgx pool for dynamic queries sqlc can't express
// (PostgREST-style document filtering builds its WHERE clause at runtime).
func (r *CoreRepository) Pool() *pgxpool.Pool {
return r.pool
}
func (r *CoreRepository) WithTx(ctx context.Context, fn func(*db.Queries) error) error {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
@@ -56,3 +62,35 @@ func (r *CoreRepository) GetAPIKeyByPrefix(ctx context.Context, prefix string) (
func (r *CoreRepository) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
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)
}
+373
View File
@@ -0,0 +1,373 @@
package services
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
// Alert rules detect conditions in telemetry (component heartbeat silence,
// error-rate spikes) and fire `alert.fired` / `alert.resolved` domain events.
// Delivery reuses the webhook dispatcher + realtime stream — the evaluator is
// the only new machinery. Rules fire on state transitions only, so a flaky
// component produces one fired + one resolved, not a storm.
const (
AlertEventFired = "alert.fired"
AlertEventResolved = "alert.resolved"
AlertKindHeartbeatSilence = "heartbeat_silence"
AlertKindErrorSpike = "error_spike"
)
// AlertRuleConfig is the JSONB config per rule kind.
//
// heartbeat_silence: component beats less often than ThresholdMinutes.
// error_spike: component logs more than ThresholdCount errors in
// WindowMinutes.
// Component empty = evaluate every component in the project.
type AlertRuleConfig struct {
Component string `json:"component,omitempty"`
ThresholdMinutes int `json:"threshold_minutes,omitempty"`
ThresholdCount int64 `json:"threshold_count,omitempty"`
WindowMinutes int `json:"window_minutes,omitempty"`
}
type AlertRuleSummary struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Config AlertRuleConfig `json:"config"`
Enabled bool `json:"enabled"`
Firing []string `json:"firing"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UpsertAlertRuleInput struct {
Name string `json:"name" validate:"required"`
Kind string `json:"kind" validate:"required"`
Config AlertRuleConfig `json:"config"`
Enabled *bool `json:"enabled"`
}
func (s *PlatformService) validateAlertRule(input UpsertAlertRuleInput) (AlertRuleConfig, error) {
if !projectSecretNameRe.MatchString(input.Name) {
return AlertRuleConfig{}, fmt.Errorf("invalid alert rule name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name)
}
cfg := input.Config
switch input.Kind {
case AlertKindHeartbeatSilence:
if cfg.ThresholdMinutes < 1 || cfg.ThresholdMinutes > 10080 {
return cfg, errors.New("threshold_minutes must be 1–10080")
}
case AlertKindErrorSpike:
if cfg.ThresholdCount < 1 {
return cfg, errors.New("threshold_count must be ≥ 1")
}
if cfg.WindowMinutes == 0 {
cfg.WindowMinutes = 15
}
if cfg.WindowMinutes < 1 || cfg.WindowMinutes > 1440 {
return cfg, errors.New("window_minutes must be 1–1440")
}
default:
return cfg, fmt.Errorf("invalid kind %q — %s or %s", input.Kind, AlertKindHeartbeatSilence, AlertKindErrorSpike)
}
if cfg.Component != "" && len(cfg.Component) > 128 {
return cfg, errors.New("component name too long")
}
return cfg, nil
}
func alertRuleSummary(row db.CoreAlertRule) AlertRuleSummary {
var cfg AlertRuleConfig
_ = json.Unmarshal(row.Config, &cfg)
firing := parseFiring(row.State)
return AlertRuleSummary{
ID: row.ID.String(),
ProjectID: row.ProjectID.String(),
Name: row.Name,
Kind: row.Kind,
Config: cfg,
Enabled: row.Enabled,
Firing: firing.list(),
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
func (s *PlatformService) ListAlertRules(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]AlertRuleSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListAlertRules(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]AlertRuleSummary, len(rows))
for i, r := range rows {
out[i] = alertRuleSummary(r)
}
return out, nil
}
func (s *PlatformService) CreateAlertRule(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input UpsertAlertRuleInput, requestID string) (AlertRuleSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return AlertRuleSummary{}, err
}
cfg, err := s.validateAlertRule(input)
if err != nil {
return AlertRuleSummary{}, err
}
rawCfg, _ := json.Marshal(cfg)
row, err := s.repo.Queries().CreateAlertRule(ctx, db.CreateAlertRuleParams{
ProjectID: projectID,
Name: input.Name,
Kind: input.Kind,
Config: rawCfg,
})
if err != nil {
if strings.Contains(err.Error(), "alert_rules_project_id_name_key") {
return AlertRuleSummary{}, fmt.Errorf("an alert rule named %s already exists", input.Name)
}
return AlertRuleSummary{}, err
}
s.auditAlert(ctx, actor, projectID, requestID, "alert.created", row.ID, input.Name)
return alertRuleSummary(row), nil
}
func (s *PlatformService) UpdateAlertRule(ctx context.Context, actor *models.Actor, projectID, ruleID uuid.UUID, input UpsertAlertRuleInput, requestID string) (AlertRuleSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return AlertRuleSummary{}, err
}
if _, err := s.repo.Queries().GetAlertRule(ctx, db.GetAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil {
return AlertRuleSummary{}, err // pgx.ErrNoRows → 404 at the handler
}
cfg, err := s.validateAlertRule(input)
if err != nil {
return AlertRuleSummary{}, err
}
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
rawCfg, _ := json.Marshal(cfg)
row, err := s.repo.Queries().UpdateAlertRule(ctx, db.UpdateAlertRuleParams{
ID: ruleID, ProjectID: projectID, Name: input.Name, Config: rawCfg, Enabled: enabled,
})
if err != nil {
return AlertRuleSummary{}, err
}
s.auditAlert(ctx, actor, projectID, requestID, "alert.updated", row.ID, input.Name)
return alertRuleSummary(row), nil
}
func (s *PlatformService) DeleteAlertRule(ctx context.Context, actor *models.Actor, projectID, ruleID uuid.UUID, requestID string) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return err
}
if _, err := s.repo.Queries().GetAlertRule(ctx, db.GetAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil {
return err
}
if err := s.repo.Queries().DeleteAlertRule(ctx, db.DeleteAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil {
return err
}
s.auditAlert(ctx, actor, projectID, requestID, "alert.deleted", ruleID, "")
return nil
}
func (s *PlatformService) auditAlert(ctx context.Context, actor *models.Actor, projectID uuid.UUID, requestID, action string, ruleID uuid.UUID, name string) {
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err != nil {
return
}
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, action, "alert_rule", ruleID.String(), map[string]any{"name": name}))
}
// ---------------------------------------------------------------------------
// evaluator — one ticker per backend, transition-based firing
// ---------------------------------------------------------------------------
type firingSet map[string]bool
func parseFiring(raw []byte) firingSet {
var state struct {
Firing map[string]bool `json:"firing"`
}
if err := json.Unmarshal(raw, &state); err != nil || state.Firing == nil {
return firingSet{}
}
return state.Firing
}
func (f firingSet) list() []string {
out := make([]string, 0, len(f))
for name, on := range f {
if on {
out = append(out, name)
}
}
return out
}
// alertQueries is the slice of db.Queries the evaluator needs — narrow so
// tests can stub it without a database.
type alertQueries interface {
ListEnabledAlertRules(ctx context.Context) ([]db.CoreAlertRule, error)
LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]db.LastHeartbeatsRow, error)
RecentErrorCounts(ctx context.Context, arg db.RecentErrorCountsParams) ([]db.RecentErrorCountsRow, error)
SetAlertRuleState(ctx context.Context, arg db.SetAlertRuleStateParams) error
}
type alertEvaluator struct {
q alertQueries
publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
logger *slog.Logger
tick time.Duration
quit chan struct{}
wg sync.WaitGroup
started sync.Once
leader *leaderState
}
func newAlertEvaluator(q alertQueries, publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), logger *slog.Logger, pool *pgxpool.Pool) *alertEvaluator {
return &alertEvaluator{q: q, publish: publish, logger: logger, tick: time.Minute, quit: make(chan struct{}), leader: newLeaderState(pool, leaderKeyAlerts)}
}
func (e *alertEvaluator) Start(ctx context.Context) {
e.started.Do(func() {
e.wg.Add(1)
go func() {
defer e.wg.Done()
t := time.NewTicker(e.tick)
defer t.Stop()
for {
select {
case <-t.C:
if e.leader.hold(ctx) {
e.scan(ctx)
}
case <-e.quit:
e.leader.stop(context.Background())
return
case <-ctx.Done():
return
}
}
}()
})
}
func (e *alertEvaluator) Stop() {
close(e.quit)
e.wg.Wait()
}
func (e *alertEvaluator) scan(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
rules, err := e.q.ListEnabledAlertRules(ctx)
if err != nil {
e.logger.Warn("alert scan: list rules", "error", err)
return
}
for _, rule := range rules {
if err := e.evaluate(ctx, rule); err != nil {
e.logger.Warn("alert scan: evaluate", "rule", rule.Name, "error", err)
}
}
}
func (e *alertEvaluator) evaluate(ctx context.Context, rule db.CoreAlertRule) error {
var cfg AlertRuleConfig
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
return nil // bad config can't be fixed by re-scanning; skip quietly
}
now := time.Now()
var breached map[string]float64 // component → measured value
var metric, unit string
switch rule.Kind {
case AlertKindHeartbeatSilence:
rows, err := e.q.LastHeartbeats(ctx, rule.ProjectID)
if err != nil {
return err
}
breached = map[string]float64{}
for _, r := range rows {
silent := now.Sub(r.LastBeat.Time).Minutes()
if silent >= float64(cfg.ThresholdMinutes) {
breached[r.ComponentName] = silent
}
}
metric, unit = "silence", "minutes"
case AlertKindErrorSpike:
rows, err := e.q.RecentErrorCounts(ctx, db.RecentErrorCountsParams{
ProjectID: rule.ProjectID,
Column2: int64(cfg.WindowMinutes) * 60,
})
if err != nil {
return err
}
breached = map[string]float64{}
for _, r := range rows {
if r.Errors >= cfg.ThresholdCount {
breached[r.ComponentName] = float64(r.Errors)
}
}
metric, unit = "errors", "count"
default:
return nil
}
firing := parseFiring(rule.State)
changed := false
// newly breached components → fired
for comp, value := range breached {
if cfg.Component != "" && comp != cfg.Component {
continue
}
if firing[comp] {
continue
}
firing[comp] = true
changed = true
e.publish(ctx, rule.ProjectID, AlertEventFired, map[string]any{
"rule_id": rule.ID.String(), "rule": rule.Name, "kind": rule.Kind,
"component": comp, "metric": metric, "value": value, "unit": unit,
})
}
// recovered components → resolved
for comp, on := range firing {
_, stillBreached := breached[comp]
if !on || stillBreached {
continue
}
firing[comp] = false
changed = true
e.publish(ctx, rule.ProjectID, AlertEventResolved, map[string]any{
"rule_id": rule.ID.String(), "rule": rule.Name, "kind": rule.Kind,
"component": comp,
})
}
if !changed {
return nil
}
raw, _ := json.Marshal(map[string]any{"firing": firing})
return e.q.SetAlertRuleState(ctx, db.SetAlertRuleStateParams{ID: rule.ID, ProjectID: rule.ProjectID, State: raw})
}
@@ -0,0 +1,162 @@
package services
import (
"context"
"encoding/json"
"log/slog"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
)
type stubAlertQueries struct {
rules []db.CoreAlertRule
beats []db.LastHeartbeatsRow
errors []db.RecentErrorCountsRow
stateSaved map[uuid.UUID][]byte
}
func (s *stubAlertQueries) ListEnabledAlertRules(ctx context.Context) ([]db.CoreAlertRule, error) {
return s.rules, nil
}
func (s *stubAlertQueries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]db.LastHeartbeatsRow, error) {
return s.beats, nil
}
func (s *stubAlertQueries) RecentErrorCounts(ctx context.Context, arg db.RecentErrorCountsParams) ([]db.RecentErrorCountsRow, error) {
return s.errors, nil
}
func (s *stubAlertQueries) SetAlertRuleState(ctx context.Context, arg db.SetAlertRuleStateParams) error {
if s.stateSaved == nil {
s.stateSaved = map[uuid.UUID][]byte{}
}
s.stateSaved[arg.ID] = arg.State
// persist back so subsequent evaluations see the transition
for i, r := range s.rules {
if r.ID == arg.ID {
s.rules[i].State = arg.State
}
}
return nil
}
func rule(t *testing.T, kind string, cfg AlertRuleConfig) db.CoreAlertRule {
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
return db.CoreAlertRule{ID: uuid.New(), ProjectID: uuid.New(), Name: "r1", Kind: kind, Config: raw, State: []byte(`{"firing":{}}`)}
}
func collect() (func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), *[]string) {
events := &[]string{}
return func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
*events = append(*events, eventType+":"+data["component"].(string))
}, events
}
func TestAlertSilenceFiresOnceThenResolves(t *testing.T) {
r := rule(t, AlertKindHeartbeatSilence, AlertRuleConfig{ThresholdMinutes: 5})
stub := &stubAlertQueries{
rules: []db.CoreAlertRule{r},
beats: []db.LastHeartbeatsRow{{
ComponentName: "web",
LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-10 * time.Minute), Valid: true},
}},
}
publish, events := collect()
e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()}
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
t.Fatal(err)
}
if len(*events) != 1 || (*events)[0] != "alert.fired:web" {
t.Fatalf("want one alert.fired:web, got %v", *events)
}
// second evaluation — still silent, must not refire
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
t.Fatal(err)
}
if len(*events) != 1 {
t.Fatalf("refired: %v", *events)
}
// heartbeat resumes → resolved
stub.beats[0].LastBeat = pgtype.Timestamptz{Time: time.Now(), Valid: true}
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
t.Fatal(err)
}
if len(*events) != 2 || (*events)[1] != "alert.resolved:web" {
t.Fatalf("want alert.resolved:web, got %v", *events)
}
}
func TestAlertErrorSpikeThreshold(t *testing.T) {
r := rule(t, AlertKindErrorSpike, AlertRuleConfig{ThresholdCount: 3, WindowMinutes: 15})
stub := &stubAlertQueries{
rules: []db.CoreAlertRule{r},
errors: []db.RecentErrorCountsRow{{ComponentName: "api", Errors: 2}},
}
publish, events := collect()
e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()}
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
t.Fatal(err)
}
if len(*events) != 0 {
t.Fatalf("under threshold fired: %v", *events)
}
stub.errors[0].Errors = 5
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
t.Fatal(err)
}
if len(*events) != 1 || (*events)[0] != "alert.fired:api" {
t.Fatalf("want alert.fired:api, got %v", *events)
}
}
func TestAlertComponentFilter(t *testing.T) {
r := rule(t, AlertKindHeartbeatSilence, AlertRuleConfig{Component: "web", ThresholdMinutes: 5})
stub := &stubAlertQueries{
rules: []db.CoreAlertRule{r},
beats: []db.LastHeartbeatsRow{
{ComponentName: "web", LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}},
{ComponentName: "api", LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}},
},
}
publish, events := collect()
e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()}
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
t.Fatal(err)
}
if len(*events) != 1 || (*events)[0] != "alert.fired:web" {
t.Fatalf("filtered rule must fire only for web, got %v", *events)
}
}
func TestValidateAlertRule(t *testing.T) {
s := &PlatformService{}
if _, err := s.validateAlertRule(UpsertAlertRuleInput{Name: "ok", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 5}}); err != nil {
t.Fatalf("valid silence rule rejected: %v", err)
}
cfg, err := s.validateAlertRule(UpsertAlertRuleInput{Name: "ok", Kind: AlertKindErrorSpike, Config: AlertRuleConfig{ThresholdCount: 3}})
if err != nil || cfg.WindowMinutes != 15 {
t.Fatalf("window default: cfg=%+v err=%v", cfg, err)
}
for _, bad := range []UpsertAlertRuleInput{
{Name: "bad name!", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 5}},
{Name: "x", Kind: "bogus", Config: AlertRuleConfig{}},
{Name: "x", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 0}},
{Name: "x", Kind: AlertKindErrorSpike, Config: AlertRuleConfig{ThresholdCount: 0}},
} {
if _, err := s.validateAlertRule(bad); err == nil {
t.Fatalf("accepted invalid rule %+v", bad)
}
}
}
+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, `\`, `\\`), `"`, `\"`) + `"`
}
@@ -0,0 +1,218 @@
package services
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/google/uuid"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
// PostgREST-lite filtering over document JSONB:
// ?filter=status.eq.active,age.gt.18,role.in.admin|editor
// ?order=name.asc,created_at.desc
// Values are always parameterized; only the whitelisted operators and
// field-path syntax below ever reach the SQL string.
var docFieldRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// documentColumns are real table columns — everything else resolves into data.
var documentColumns = map[string]string{
"id": "id",
"created_at": "created_at",
"updated_at": "updated_at",
}
// docFieldExpr maps a (possibly dotted) field path to a SQL text expression.
// data #>> '{a,b}' extracts nested values as text — the correct comparison
// domain for loosely-typed document fields.
func docFieldExpr(field string) (string, error) {
if col, ok := documentColumns[field]; ok {
return col, nil
}
parts := strings.Split(field, ".")
for _, p := range parts {
if !docFieldRe.MatchString(p) {
return "", fmt.Errorf("invalid field name %q — use [A-Za-z_][A-Za-z0-9_]* segments", field)
}
}
return fmt.Sprintf("data #>> '{%s}'", strings.Join(parts, ",")), nil
}
// numericExpr wraps the field for ordered comparisons — only used when the
// filter value itself parses as a float, so the cast can never fail.
func numericExpr(expr string) string {
return fmt.Sprintf("(%s)::numeric", expr)
}
// buildDocumentFilters turns "field.op.value" terms into parameterized SQL.
// Returns the WHERE fragments and the argument list (already $N-numbered via
// startArg). A fragment is empty for `is` filters since they carry no value.
func buildDocumentFilters(filterParam string, startArg int) ([]string, []any, error) {
var conds []string
var args []any
argN := startArg
next := func(v any) string {
args = append(args, v)
argN++
return fmt.Sprintf("$%d", argN-1)
}
var docOps = []string{"eq", "neq", "gt", "gte", "lt", "lte", "like", "in", "is"}
for _, term := range strings.Split(filterParam, ",") {
term = strings.TrimSpace(term)
if term == "" {
continue
}
// Field paths are themselves dotted, so the operator is located by
// scanning for the first ".<known-op>." — the value may contain dots.
var field, op, value string
best := -1
for _, candidate := range docOps {
needle := "." + candidate + "."
if i := strings.Index(term, needle); i > 0 && (best < 0 || i < best) {
best = i
field, op, value = term[:i], candidate, term[i+len(needle):]
}
}
if field == "" {
return nil, nil, fmt.Errorf("invalid filter %q — expected field.op.value (ops: %s)", term, strings.Join(docOps, ", "))
}
expr, err := docFieldExpr(field)
if err != nil {
return nil, nil, err
}
_, isFloat := strconv.ParseFloat(value, 64)
switch op {
case "eq", "neq":
sym := "="
if op == "neq" {
sym = "<>"
}
conds = append(conds, fmt.Sprintf("%s %s %s", expr, sym, next(value)))
case "gt", "gte", "lt", "lte":
if isFloat != nil {
return nil, nil, fmt.Errorf("filter %q: %s requires a numeric value", term, op)
}
sym := map[string]string{"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op]
f, _ := strconv.ParseFloat(value, 64)
conds = append(conds, fmt.Sprintf("%s %s %s", numericExpr(expr), sym, next(f)))
case "like":
conds = append(conds, fmt.Sprintf("%s ILIKE %s", expr, next(value)))
case "in":
items := strings.Split(value, "|")
for i, item := range items {
items[i] = strings.TrimSpace(item)
}
conds = append(conds, fmt.Sprintf("%s = ANY(%s)", expr, next(items)))
case "is":
switch value {
case "null":
conds = append(conds, fmt.Sprintf("%s IS NULL", expr))
case "true", "false":
conds = append(conds, fmt.Sprintf("%s = %s", expr, next(value)))
default:
return nil, nil, fmt.Errorf("filter %q: is accepts null|true|false", term)
}
default:
return nil, nil, fmt.Errorf("unknown operator %q in filter %q — supported: eq, neq, gt, gte, lt, lte, like, in, is", op, term)
}
}
return conds, args, nil
}
// buildDocumentOrder turns "name.asc,created_at.desc" into an ORDER BY list.
func buildDocumentOrder(orderParam string) (string, error) {
if orderParam == "" {
return "created_at DESC", nil
}
var parts []string
for _, term := range strings.Split(orderParam, ",") {
term = strings.TrimSpace(term)
if term == "" {
continue
}
// Direction is a suffix, not a split — field paths are dotted too.
field, dir := term, "asc"
if strings.HasSuffix(term, ".desc") {
field, dir = strings.TrimSuffix(term, ".desc"), "desc"
} else if strings.HasSuffix(term, ".asc") {
field = strings.TrimSuffix(term, ".asc")
}
expr, err := docFieldExpr(field)
if err != nil {
return "", err
}
parts = append(parts, expr+" "+strings.ToUpper(dir))
}
if len(parts) == 0 {
return "created_at DESC", nil
}
return strings.Join(parts, ", "), nil
}
// SearchDocuments lists documents with PostgREST-style filter/order params.
// Returns the page plus the un-paginated match count for "total".
func (s *PlatformService) SearchDocuments(
ctx context.Context,
actor *models.Actor,
collectionID uuid.UUID,
filterParam, orderParam string,
limit, offset int32,
) ([]db.CoreDocument, int64, error) {
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
if err != nil {
return nil, 0, err
}
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer", "viewer"); err != nil {
return nil, 0, err
}
args := []any{collectionID}
conds, filterArgs, err := buildDocumentFilters(filterParam, len(args)+1)
if err != nil {
return nil, 0, err
}
args = append(args, filterArgs...)
where := "collection_id = $1"
if len(conds) > 0 {
where += " AND " + strings.Join(conds, " AND ")
}
order, err := buildDocumentOrder(orderParam)
if err != nil {
return nil, 0, err
}
var total int64
if err := s.repo.Pool().QueryRow(ctx,
fmt.Sprintf("SELECT COUNT(*) FROM core.documents WHERE %s", where), args...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, limit, offset)
rows, err := s.repo.Pool().Query(ctx, fmt.Sprintf(
"SELECT * FROM core.documents WHERE %s ORDER BY %s LIMIT $%d OFFSET $%d",
where, order, len(args)-1, len(args)), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []db.CoreDocument
for rows.Next() {
var d db.CoreDocument
if err := rows.Scan(&d.ID, &d.CollectionID, &d.Data, &d.CreatedByUserID, &d.CreatedAt, &d.UpdatedAt); err != nil {
return nil, 0, err
}
out = append(out, d)
}
return out, total, rows.Err()
}
@@ -0,0 +1,119 @@
package services
import (
"reflect"
"strings"
"testing"
)
func TestBuildDocumentFilters(t *testing.T) {
t.Run("eq on a nested path uses #>>", func(t *testing.T) {
conds, args, err := buildDocumentFilters("meta.city.eq.Prague", 2)
if err != nil {
t.Fatalf("err: %v", err)
}
want := "data #>> '{meta,city}' = $2"
if conds[0] != want || !reflect.DeepEqual(args, []any{"Prague"}) {
t.Fatalf("got %v %v", conds, args)
}
})
t.Run("numeric ops cast to ::numeric", func(t *testing.T) {
conds, args, err := buildDocumentFilters("age.gt.18,age.lt.65", 2)
if err != nil {
t.Fatalf("err: %v", err)
}
if !strings.Contains(conds[0], "::numeric > $2") || !strings.Contains(conds[1], "::numeric < $3") {
t.Fatalf("got %v", conds)
}
if args[0] != 18.0 || args[1] != 65.0 {
t.Fatalf("args: %v", args)
}
})
t.Run("numeric op rejects non-numeric value", func(t *testing.T) {
_, _, err := buildDocumentFilters("age.gt.abc", 2)
if err == nil {
t.Fatal("expected error")
}
})
t.Run("like becomes ILIKE", func(t *testing.T) {
conds, _, err := buildDocumentFilters("name.like.%_smith%", 2)
if err != nil || conds[0] != "data #>> '{name}' ILIKE $2" {
t.Fatalf("got %v %v", conds, err)
}
})
t.Run("in becomes ANY with a text array", func(t *testing.T) {
conds, args, err := buildDocumentFilters("role.in.admin|editor", 2)
if err != nil || !strings.Contains(conds[0], "= ANY($2)") {
t.Fatalf("got %v", conds)
}
if !reflect.DeepEqual(args[0], []string{"admin", "editor"}) {
t.Fatalf("args: %v", args)
}
})
t.Run("is null carries no argument", func(t *testing.T) {
conds, args, err := buildDocumentFilters("deleted_at.is.null", 2)
if err != nil || len(args) != 0 || conds[0] != "data #>> '{deleted_at}' IS NULL" {
t.Fatalf("got %v %v", conds, args)
}
})
t.Run("rejects injection-shaped field", func(t *testing.T) {
_, _, err := buildDocumentFilters("x' OR '1'='1.eq.a", 2)
if err == nil {
t.Fatal("expected error")
}
})
t.Run("rejects term without a known operator", func(t *testing.T) {
_, _, err := buildDocumentFilters("age.between.5", 2)
if err == nil || !strings.Contains(err.Error(), "invalid filter") {
t.Fatalf("got %v", err)
}
})
t.Run("malformed term errors", func(t *testing.T) {
_, _, err := buildDocumentFilters("justafield", 2)
if err == nil {
t.Fatal("expected error")
}
})
t.Run("real columns resolve directly", func(t *testing.T) {
conds, _, err := buildDocumentFilters("created_at.gt.2026-01-01", 2)
// created_at is a column — but value isn't numeric so gt must fail
if err == nil {
t.Fatalf("expected numeric-required error, got %v", conds)
}
})
}
func TestBuildDocumentOrder(t *testing.T) {
cases := map[string]string{
"": "created_at DESC",
"name.asc": "data #>> '{name}' ASC",
"name": "data #>> '{name}' ASC",
"created_at.desc": "created_at DESC",
"a.b.asc,id.desc": "data #>> '{a,b}' ASC, id DESC",
}
for in, want := range cases {
got, err := buildDocumentOrder(in)
if err != nil {
t.Fatalf("%q: %v", in, err)
}
if got != want {
t.Errorf("%q: got %q want %q", in, got, want)
}
}
// A non-asc/desc suffix is a nested field path, not a direction error.
if got, err := buildDocumentOrder("name.sideways"); err != nil || got != "data #>> '{name,sideways}' ASC" {
t.Errorf("nested order path: got %q err %v", got, err)
}
if _, err := buildDocumentOrder("x;drop.asc"); err == nil {
t.Error("expected field error")
}
}
@@ -0,0 +1,22 @@
package services
import "testing"
func TestCustomEventTypeRe(t *testing.T) {
good := []string{"custom.ping", "custom.deploy.done", "custom.a", "custom.x-y_z.9"}
bad := []string{
"", "custom", "custom.", "document.created", "CUSTOM.ping",
"custom.Ping", "custom..x", "custom.-x", "custom." + string(rune(0x20)) + "x",
"custom." + string(make([]byte, 64)),
}
for _, s := range good {
if !customEventTypeRe.MatchString(s) {
t.Errorf("expected %q to match", s)
}
}
for _, s := range bad {
if customEventTypeRe.MatchString(s) {
t.Errorf("expected %q to be rejected", s)
}
}
}
@@ -0,0 +1,137 @@
package services
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// dockerRunner executes functions in an ephemeral, locked-down container:
// no network, read-only rootfs, memory/CPU/pid caps, non-root, all
// capabilities dropped. The source is passed through the environment as
// base64 and materialized inside the container's tmpfs — no bind mount,
// so this works on Docker Desktop (VM daemon) and remote daemons where
// host paths aren't shared. The payload arrives on stdin — same contract
// as execRunner, but user code no longer inherits backend privileges.
// Requires the Docker socket (compose: mount /var/run/docker.sock) — the
// documented trade-off: socket access is root-equivalent on the host, so
// this driver isolates function code, not operators.
type dockerRunner struct {
timeout time.Duration
maxOutput int64
images map[string]string // runtime -> image ref
}
// maxFnSourceBytes caps function source for the docker driver — the source
// travels in the container spec (env), which kernels limit to ~2 MB total;
// 256 KiB keeps argv+env comfortably inside it.
const maxFnSourceBytes = 256 << 10
func NewDockerRunner(timeout time.Duration, maxOutput int64) *dockerRunner {
return &dockerRunner{
timeout: timeout,
maxOutput: maxOutput,
images: map[string]string{
"bun": "oven/bun:1.3.14",
"deno": "denoland/deno:2.5.6",
},
}
}
func (r *dockerRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult {
image, ok := r.images[runtime]
if !ok {
return FunctionResult{Status: "error", Stderr: "no container image configured for runtime " + runtime}
}
if len(code) > maxFnSourceBytes {
return FunctionResult{Status: "error", Stderr: fmt.Sprintf("function source exceeds %d KiB docker-driver limit", maxFnSourceBytes>>10)}
}
if _, err := exec.LookPath("docker"); err != nil {
return FunctionResult{Status: "error", Stderr: "FUNCTIONS_DRIVER=docker but docker CLI not found"}
}
ctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
// Named container so a timed-out client doesn't orphan a running fn.
var suffix [4]byte
_, _ = rand.Read(suffix[:])
name := "primora-fn-" + hex.EncodeToString(suffix[:])
// Write source inside the container, then run it. base64 keeps the
// code inert data — it is decoded by the shell, never interpreted.
entrypoint := fmt.Sprintf(
`echo "$PRIMORA_CODE" | base64 -d > /tmp/fn.ts && %s /tmp/fn.ts`,
map[bool]string{true: "deno run --no-prompt", false: "bun run"}[runtime == "deno"],
)
args := []string{
"run", "--rm", "-i", "--name", name,
"--network", "none",
"--memory", "128m", "--memory-swap", "128m",
"--cpus", "0.5",
"--pids-limit", "64",
"--read-only",
"--tmpfs", "/tmp:rw,noexec,nosuid,size=16m",
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--user", "65534:65534",
"-e", "PRIMORA_CODE=" + base64.StdEncoding.EncodeToString([]byte(code)),
"-e", "PRIMORA_PAYLOAD=" + string(payload),
"-e", "PRIMORA_FUNCTION_RUNTIME=" + runtime,
"--entrypoint", "sh",
image, "-c", entrypoint,
}
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Stdin = bytes.NewReader(payload)
var stdout, stderr bytes.Buffer
cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput}
cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput}
// ctx cancel kills the docker CLI but the daemon keeps the container
// running — force-remove it so a stuck function can't linger.
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = exec.Command("docker", "rm", "-f", name).Run()
case <-done:
}
}()
defer close(done)
start := time.Now()
runErr := cmd.Run()
duration := time.Since(start).Milliseconds()
res := FunctionResult{
Status: "success",
Stdout: stdout.String(),
Stderr: stderr.String(),
DurationMs: duration,
}
if ctx.Err() == context.DeadlineExceeded {
res.Status = "timeout"
res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out")
return res
}
if runErr != nil {
res.Status = "error"
if exitErr, ok := runErr.(*exec.ExitError); ok {
code := int32(exitErr.ExitCode())
res.ExitCode = &code
} else {
res.Stderr = strings.TrimSpace(fmt.Sprintf("%v\n%s", runErr, res.Stderr))
}
}
return res
}
@@ -0,0 +1,59 @@
package services
import (
"context"
"encoding/json"
"os/exec"
"strings"
"testing"
"time"
)
func dockerAvailable(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker CLI not installed")
}
if err := exec.Command("docker", "info").Run(); err != nil {
t.Skip("docker daemon not reachable")
}
// The test pulls the runtime image on first run.
if err := exec.Command("docker", "image", "inspect", "oven/bun:1.3.14").Run(); err != nil {
t.Skip("oven/bun:1.3.14 image not pulled")
}
}
func TestDockerRunnerRunsCode(t *testing.T) {
dockerAvailable(t)
r := NewDockerRunner(30*time.Second, 65536)
res := r.Run(context.Background(), "bun",
`const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("hi " + p.name);`,
json.RawMessage(`{"name":"sandbox"}`))
if res.Status != "success" {
t.Fatalf("status = %q stderr = %q", res.Status, res.Stderr)
}
if !strings.Contains(res.Stdout, "hi sandbox") {
t.Fatalf("stdout = %q", res.Stdout)
}
}
func TestDockerRunnerIsolatesNetwork(t *testing.T) {
dockerAvailable(t)
r := NewDockerRunner(30*time.Second, 65536)
// --network none: even a DNS/connect attempt must fail inside.
res := r.Run(context.Background(), "bun",
`try { await fetch("http://169.254.169.254/"); console.log("NET-OPEN") } catch { console.log("NET-CLOSED") }`,
json.RawMessage(`{}`))
if !strings.Contains(res.Stdout, "NET-CLOSED") {
t.Fatalf("network should be unreachable, stdout = %q stderr = %q status = %q", res.Stdout, res.Stderr, res.Status)
}
}
func TestDockerRunnerTimesOut(t *testing.T) {
dockerAvailable(t)
r := NewDockerRunner(2*time.Second, 65536)
res := r.Run(context.Background(), "bun", `while (true) {}`, json.RawMessage(`{}`))
if res.Status != "timeout" {
t.Fatalf("status = %q", res.Status)
}
}
+474
View File
@@ -0,0 +1,474 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
var functionNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`)
// FunctionRunner executes a function's code with a JSON payload on stdin
// and returns the outcome. Implemented by execRunner (a runtime binary on
// the backend host); tests substitute a fake.
type FunctionRunner interface {
Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult
}
// FunctionResult is one execution's captured outcome.
type FunctionResult struct {
Status string // success | error | timeout
ExitCode *int32
Stdout string
Stderr string
DurationMs int64
}
// execRunner shells out to a JS runtime binary. User code runs with the
// backend's privileges — this is the documented self-hosted model (same as
// n8n custom-code nodes); operators harden at the container/VM boundary.
// jarvis: ceiling unsandboxed exec; upgrade to Firecracker/gVisor or a
// sidecar runtime pool when multi-tenant isolation is needed.
type execRunner struct {
bin string
timeout time.Duration
maxOutput int64
resolveBin func(runtime string) string
}
func NewExecRunner(bin string, timeout time.Duration, maxOutput int64) *execRunner {
return &execRunner{
bin: bin,
timeout: timeout,
maxOutput: maxOutput,
resolveBin: func(runtime string) string {
if bin != "" && bin != "auto" {
return bin
}
return runtime // "bun" or "deno" from PATH
},
}
}
func (r *execRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult {
bin := r.resolveBin(runtime)
if bin == "" {
return FunctionResult{Status: "error", Stderr: "no functions runtime configured"}
}
dir, err := os.MkdirTemp("", "primora-fn-*")
if err != nil {
return FunctionResult{Status: "error", Stderr: err.Error()}
}
defer os.RemoveAll(dir)
file := filepath.Join(dir, "fn.ts")
if err := os.WriteFile(file, []byte(code), 0o600); err != nil {
return FunctionResult{Status: "error", Stderr: err.Error()}
}
ctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
var cmd *exec.Cmd
switch runtime {
case "deno":
// --no-prompt: never block on interactive permission prompts.
cmd = exec.CommandContext(ctx, bin, "run", "--no-prompt", file)
default: // bun
cmd = exec.CommandContext(ctx, bin, "run", file)
}
cmd.Stdin = bytes.NewReader(payload)
cmd.Env = append(os.Environ(),
"PRIMORA_PAYLOAD="+string(payload),
"PRIMORA_FUNCTION_RUNTIME="+runtime,
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput}
cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput}
start := time.Now()
runErr := cmd.Run()
duration := time.Since(start).Milliseconds()
res := FunctionResult{
Status: "success",
Stdout: stdout.String(),
Stderr: stderr.String(),
DurationMs: duration,
}
if ctx.Err() == context.DeadlineExceeded {
res.Status = "timeout"
res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out")
return res
}
if runErr != nil {
res.Status = "error"
if exitErr, ok := runErr.(*exec.ExitError); ok {
code := int32(exitErr.ExitCode())
res.ExitCode = &code
} else {
res.Stderr = strings.TrimSpace(runErr.Error() + "\n" + res.Stderr)
}
}
return res
}
// limitedWriter caps captured output; excess bytes are discarded, not
// stored — a chatty function can't grow function_runs rows unboundedly.
type limitedWriter struct {
w *bytes.Buffer
max int64
}
func (l *limitedWriter) Write(p []byte) (int, error) {
remaining := l.max - int64(l.w.Len())
if remaining > 0 {
if int64(len(p)) > remaining {
l.w.Write(p[:remaining])
} else {
l.w.Write(p)
}
}
return len(p), nil
}
// --- service layer -------------------------------------------------------
type CreateFunctionInput struct {
Name string `json:"name" validate:"required"`
Code string `json:"code" validate:"required,max=262144"`
Runtime string `json:"runtime" validate:"omitempty,oneof=bun deno"`
EventPattern string `json:"event_pattern" validate:"omitempty,max=128"`
}
type UpdateFunctionInput struct {
Code *string `json:"code" validate:"omitempty,max=262144"`
Enabled *bool `json:"enabled"`
EventPattern *string `json:"event_pattern" validate:"omitempty,max=128"`
}
type FunctionSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Runtime string `json:"runtime"`
Enabled bool `json:"enabled"`
EventPattern string `json:"event_pattern,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type FunctionRunSummary struct {
ID string `json:"id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
ExitCode *int32 `json:"exit_code,omitempty"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
DurationMs int64 `json:"duration_ms"`
CreatedAt time.Time `json:"created_at"`
}
func functionSummary(r db.CoreFunction) FunctionSummary {
return FunctionSummary{
ID: r.ID.String(),
Name: r.Name,
Runtime: r.Runtime,
Enabled: r.Enabled,
EventPattern: r.EventPattern,
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
}
func runSummary(r db.CoreFunctionRun) FunctionRunSummary {
return FunctionRunSummary{
ID: r.ID.String(),
Trigger: r.Trigger,
Status: r.Status,
ExitCode: r.ExitCode,
Stdout: r.Stdout,
Stderr: r.Stderr,
DurationMs: int64(r.DurationMs),
CreatedAt: r.CreatedAt.Time,
}
}
func (s *PlatformService) CreateFunction(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateFunctionInput, requestID string) (FunctionSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return FunctionSummary{}, err
}
name := strings.TrimSpace(input.Name)
if !functionNamePattern.MatchString(name) {
return FunctionSummary{}, fmt.Errorf("invalid function name")
}
runtime := input.Runtime
if runtime == "" {
runtime = "bun"
}
row, err := s.repo.Queries().CreateFunction(ctx, db.CreateFunctionParams{
ProjectID: projectID,
Name: name,
Code: input.Code,
Runtime: runtime,
EventPattern: strings.TrimSpace(input.EventPattern),
})
if err != nil {
return FunctionSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "function.created", "function", row.ID.String(), map[string]any{
"name": row.Name, "runtime": row.Runtime,
}))
}
return functionSummary(row), nil
}
func (s *PlatformService) ListFunctions(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]FunctionSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListFunctions(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]FunctionSummary, len(rows))
for i, r := range rows {
out[i] = functionSummary(r)
}
return out, nil
}
// GetFunctionCode returns the function's source — code is not a secret,
// project members already see it in the dashboard editor.
func (s *PlatformService) GetFunctionCode(ctx context.Context, actor *models.Actor, functionID uuid.UUID) (FunctionSummary, string, error) {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return FunctionSummary{}, "", err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil {
return FunctionSummary{}, "", err
}
return functionSummary(row), row.Code, nil
}
func (s *PlatformService) UpdateFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, input UpdateFunctionInput, requestID string) (FunctionSummary, error) {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return FunctionSummary{}, err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
return FunctionSummary{}, err
}
var pattern *string
if input.EventPattern != nil {
p := strings.TrimSpace(*input.EventPattern)
pattern = &p
}
updated, err := s.repo.Queries().UpdateFunction(ctx, db.UpdateFunctionParams{
ID: functionID,
ProjectID: row.ProjectID,
Code: input.Code,
Enabled: input.Enabled,
EventPattern: pattern,
})
if err != nil {
return FunctionSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.updated", "function", functionID.String(), map[string]any{
"name": updated.Name,
}))
}
return functionSummary(updated), nil
}
func (s *PlatformService) DeleteFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, requestID string) error {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
return err
}
if err := s.repo.Queries().DeleteFunction(ctx, db.DeleteFunctionParams{
ID: functionID,
ProjectID: row.ProjectID,
}); err != nil {
return err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.deleted", "function", functionID.String(), map[string]any{
"name": row.Name,
}))
}
return nil
}
// InvokeFunction executes a function with a JSON payload, records the run,
// and returns its summary.
func (s *PlatformService) InvokeFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, payload json.RawMessage, requestID string) (FunctionRunSummary, error) {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return FunctionRunSummary{}, err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
return FunctionRunSummary{}, err
}
summary, err := s.executeFunction(ctx, row, payload, "manual")
if err != nil {
return FunctionRunSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.invoked", "function", functionID.String(), map[string]any{
"name": row.Name, "status": summary.Status,
}))
}
return summary, nil
}
// executeFunction runs a function row and records the outcome — shared by
// manual invoke, schedule/hook delivery, and event triggers (no role check;
// callers hold their own auth context or are system paths).
func (s *PlatformService) executeFunction(ctx context.Context, row db.CoreFunction, payload json.RawMessage, trigger string) (FunctionRunSummary, error) {
if !row.Enabled {
return FunctionRunSummary{}, fmt.Errorf("function is disabled")
}
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
if !json.Valid(payload) {
return FunctionRunSummary{}, fmt.Errorf("payload must be valid JSON")
}
if s.functions == nil {
return FunctionRunSummary{}, fmt.Errorf("functions runtime not configured")
}
result := s.functions.Run(ctx, row.Runtime, row.Code, payload)
var exit *int32
if result.ExitCode != nil {
exit = result.ExitCode
}
run, err := s.repo.Queries().InsertFunctionRun(ctx, db.InsertFunctionRunParams{
FunctionID: row.ID,
Trigger: trigger,
Status: result.Status,
ExitCode: exit,
Stdout: result.Stdout,
Stderr: result.Stderr,
DurationMs: int32(result.DurationMs),
})
if err != nil {
return FunctionRunSummary{}, err
}
return runSummary(run), nil
}
// runJobFunction is the scheduler's delivery path for function-targeted
// jobs: load the function, run it with the resolved payload, record the
// run. The job run's status_code mirrors the process exit code.
func (s *PlatformService) runJobFunction(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error) {
fn, err := s.repo.Queries().GetFunction(ctx, uuid.UUID(job.FunctionID.Bytes))
if err != nil {
return nil, fmt.Errorf("target function missing: %w", err)
}
summary, err := s.executeFunction(ctx, fn, payload, triggerFromJobRun(run.TriggeredBy))
if err != nil {
return nil, err
}
code := int32(0)
if summary.ExitCode != nil {
code = *summary.ExitCode
}
if summary.Status != "success" {
return &code, fmt.Errorf("function %q %s: %s", fn.Name, summary.Status, truncate(summary.Stderr, 200))
}
return &code, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
func triggerFromJobRun(triggeredBy string) string {
switch triggeredBy {
case "schedule", "hook", "manual":
return triggeredBy
default:
return "schedule"
}
}
// triggerFunctionsForEvent fans a domain event out to every enabled
// function whose event_pattern matches ("document.*", "*", …). Runs are
// detached — event publishers never block on user code.
// jarvis: ceiling pattern is a single * suffix/prefix wildcard via SQL
// LIKE; upgrade to a real matcher if multi-segment globs are needed.
func (s *PlatformService) triggerFunctionsForEvent(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
if s.functions == nil {
return
}
rows, err := s.repo.Queries().ListFunctionsForEvent(ctx, db.ListFunctionsForEventParams{
ProjectID: projectID,
EventPattern: eventType,
})
if err != nil || len(rows) == 0 {
return
}
payload, _ := json.Marshal(map[string]any{
"event": eventType,
"project_id": projectID.String(),
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": data,
})
for _, fn := range rows {
fn := fn
go func() {
// Detached context: the publishing request may finish first.
runCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if _, err := s.executeFunction(runCtx, fn, payload, "event"); err != nil {
s.logger.Warn("function event trigger failed", "function", fn.Name, "event", eventType, "error", err)
}
}()
}
}
func (s *PlatformService) ListFunctionRuns(ctx context.Context, actor *models.Actor, functionID uuid.UUID, limit int64) ([]FunctionRunSummary, error) {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return nil, err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.repo.Queries().ListFunctionRuns(ctx, db.ListFunctionRunsParams{
FunctionID: row.ID,
Limit: int32(limit),
})
if err != nil {
return nil, err
}
out := make([]FunctionRunSummary, len(rows))
for i, r := range rows {
out[i] = runSummary(r)
}
return out, nil
}
@@ -0,0 +1,86 @@
package services
import (
"bytes"
"context"
"strings"
"testing"
"time"
)
// These tests run the real bun binary — skipped when it is not installed,
// same convention as exec-dependent tests elsewhere.
func bunAvailable(t *testing.T) {
t.Helper()
r := NewExecRunner("auto", 5*time.Second, 4096)
res := r.Run(context.Background(), "bun", `console.log("probe")`, nil)
if strings.Contains(res.Stderr, "no functions runtime") || (res.Status == "error" && res.ExitCode == nil && strings.Contains(res.Stderr, "executable file not found")) {
t.Skip("bun not installed")
}
}
func TestExecRunnerSuccess(t *testing.T) {
bunAvailable(t)
r := NewExecRunner("auto", 10*time.Second, 4096)
res := r.Run(context.Background(), "bun",
`const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("got:" + p.x);`,
[]byte(`{"x":42}`))
if res.Status != "success" {
t.Fatalf("status=%s stderr=%s", res.Status, res.Stderr)
}
if !strings.Contains(res.Stdout, "got:42") {
t.Fatalf("stdout=%q", res.Stdout)
}
if res.DurationMs <= 0 {
t.Fatalf("duration=%d", res.DurationMs)
}
}
func TestExecRunnerErrorCapturesStderr(t *testing.T) {
bunAvailable(t)
r := NewExecRunner("auto", 10*time.Second, 4096)
res := r.Run(context.Background(), "bun", `throw new Error("test-boom")`, nil)
if res.Status != "error" {
t.Fatalf("status=%s", res.Status)
}
if res.ExitCode == nil || *res.ExitCode == 0 {
t.Fatalf("exit=%v", res.ExitCode)
}
if !strings.Contains(res.Stderr, "test-boom") {
t.Fatalf("stderr=%q", res.Stderr)
}
}
func TestExecRunnerTimeout(t *testing.T) {
bunAvailable(t)
r := NewExecRunner("auto", 500*time.Millisecond, 4096)
res := r.Run(context.Background(), "bun", `await new Promise(r => setTimeout(r, 10000))`, nil)
if res.Status != "timeout" {
t.Fatalf("status=%s", res.Status)
}
}
func TestLimitedWriterCaps(t *testing.T) {
var buf bytes.Buffer
lw := &limitedWriter{w: &buf, max: 5}
if n, err := lw.Write([]byte("hello world")); err != nil || n != 11 {
t.Fatalf("write n=%d err=%v", n, err)
}
if buf.String() != "hello" {
t.Fatalf("got %q", buf.String())
}
}
func TestTriggerFromJobRun(t *testing.T) {
for in, want := range map[string]string{
"schedule": "schedule",
"hook": "hook",
"manual": "manual",
"cli": "schedule",
"": "schedule",
} {
if got := triggerFromJobRun(in); got != want {
t.Fatalf("triggerFromJobRun(%q) = %q, want %q", in, got, want)
}
}
}
+293
View File
@@ -0,0 +1,293 @@
package services
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
// Inbound hooks are project-scoped public ingest endpoints:
// POST /api/v1/hooks/<token>. A hook either republishes the body as an
// `inbound.received` domain event (webhooks + realtime fan out for free) or
// triggers a scheduled-job run with the body as the payload. When a secret is
// set, callers must sign the raw body with X-Primora-Signature — the same
// HMAC-SHA256 scheme outbound deliveries use.
const (
WebhookEventInboundReceived = "inbound.received"
InboundModeEvent = "event"
InboundModeJob = "job"
InboundModeFunction = "function"
)
type InboundHookSummary struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Name string `json:"name"`
Token string `json:"token"`
URL string `json:"url"`
Mode string `json:"mode"`
JobID string `json:"job_id,omitempty"`
FunctionID string `json:"function_id,omitempty"`
HasSecret bool `json:"has_secret"`
Enabled bool `json:"enabled"`
LastReceivedAt *time.Time `json:"last_received_at"`
CreatedAt time.Time `json:"created_at"`
}
type CreateInboundHookInput struct {
Name string `json:"name" validate:"required"`
Mode string `json:"mode"`
JobID string `json:"job_id"`
FunctionID string `json:"function_id"`
Secret string `json:"secret"`
Enabled *bool `json:"enabled"`
}
type InboundReceiveResult struct {
Received bool `json:"received"`
Event string `json:"event,omitempty"`
RunID string `json:"run_id,omitempty"`
}
func inboundHookSummary(row db.CoreInboundHook, publicURL string) InboundHookSummary {
out := InboundHookSummary{
ID: row.ID.String(),
ProjectID: row.ProjectID.String(),
Name: row.Name,
Token: row.Token,
URL: strings.TrimSuffix(publicURL, "/") + "/api/v1/hooks/" + row.Token,
Mode: row.Mode,
HasSecret: len(row.Secret) > 0,
Enabled: row.Enabled,
CreatedAt: row.CreatedAt.Time,
}
if row.JobID.Valid {
out.JobID = uuid.UUID(row.JobID.Bytes).String()
}
if row.FunctionID.Valid {
out.FunctionID = uuid.UUID(row.FunctionID.Bytes).String()
}
if row.LastReceivedAt.Valid {
t := row.LastReceivedAt.Time
out.LastReceivedAt = &t
}
return out
}
func (s *PlatformService) ListInboundHooks(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]InboundHookSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListInboundHooks(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]InboundHookSummary, len(rows))
for i, r := range rows {
out[i] = inboundHookSummary(r, s.publicURL)
}
return out, nil
}
func (s *PlatformService) CreateInboundHook(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateInboundHookInput, requestID string) (InboundHookSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return InboundHookSummary{}, err
}
if !projectSecretNameRe.MatchString(input.Name) {
return InboundHookSummary{}, fmt.Errorf("invalid hook name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name)
}
mode := input.Mode
if mode == "" {
mode = InboundModeEvent
}
var jobID, functionID pgtype.UUID
switch mode {
case InboundModeJob:
parsed, err := uuid.Parse(input.JobID)
if err != nil {
return InboundHookSummary{}, errors.New("job mode requires a valid job_id")
}
job, err := s.repo.Queries().GetScheduledJobByID(ctx, parsed)
if err != nil || job.ProjectID != projectID {
return InboundHookSummary{}, errors.New("job not found in this project")
}
jobID = pgtype.UUID{Bytes: parsed, Valid: true}
case InboundModeFunction:
parsed, err := uuid.Parse(input.FunctionID)
if err != nil {
return InboundHookSummary{}, errors.New("function mode requires a valid function_id")
}
fn, err := s.repo.Queries().GetFunction(ctx, parsed)
if err != nil || fn.ProjectID != projectID {
return InboundHookSummary{}, errors.New("function not found in this project")
}
functionID = pgtype.UUID{Bytes: parsed, Valid: true}
case InboundModeEvent:
default:
return InboundHookSummary{}, fmt.Errorf("invalid mode %q — event, job, or function", input.Mode)
}
secretBytes := []byte{}
if input.Secret != "" {
if s.enc == nil {
return InboundHookSummary{}, errors.New("encryption is not configured — cannot store hook secrets")
}
var err error
if secretBytes, err = s.enc.Encrypt([]byte(input.Secret)); err != nil {
return InboundHookSummary{}, err
}
}
token := "prm_hook_" + randHex(16)
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
row, err := s.repo.Queries().CreateInboundHook(ctx, db.CreateInboundHookParams{
ProjectID: projectID,
Name: input.Name,
Token: token,
Mode: mode,
JobID: jobID,
FunctionID: functionID,
Secret: secretBytes,
Enabled: enabled,
})
if err != nil {
if strings.Contains(err.Error(), "inbound_hooks_project_id_name_key") {
return InboundHookSummary{}, fmt.Errorf("an inbound hook named %s already exists", input.Name)
}
return InboundHookSummary{}, err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.created", "inbound_hook", row.ID.String(), map[string]any{
"name": row.Name, "mode": row.Mode,
}))
}
return inboundHookSummary(row, s.publicURL), nil
}
func (s *PlatformService) DeleteInboundHook(ctx context.Context, actor *models.Actor, projectID, hookID uuid.UUID, requestID string) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return err
}
if _, err := s.repo.Queries().GetInboundHook(ctx, db.GetInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
return err
}
if err := s.repo.Queries().DeleteInboundHook(ctx, db.DeleteInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
return err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.deleted", "inbound_hook", hookID.String(), nil))
}
return nil
}
// ReceiveInboundHook handles POST /api/v1/hooks/:token — unauthenticated; the
// token is the credential. Signature check applies when a secret is set.
func (s *PlatformService) ReceiveInboundHook(ctx context.Context, token string, signature string, body []byte) (InboundReceiveResult, error) {
hook, err := s.repo.Queries().GetInboundHookByToken(ctx, token)
if err != nil {
return InboundReceiveResult{}, err // pgx.ErrNoRows → 404
}
if !hook.Enabled {
return InboundReceiveResult{}, errors.New("hook is disabled")
}
if len(hook.Secret) > 0 {
if s.enc == nil {
return InboundReceiveResult{}, errors.New("encryption is not configured")
}
plain, err := s.enc.Decrypt(hook.Secret)
if err != nil {
return InboundReceiveResult{}, err
}
want := "sha256=" + signBody(string(plain), body)
if subtle.ConstantTimeCompare([]byte(signature), []byte(want)) != 1 {
return InboundReceiveResult{}, errors.New("invalid signature")
}
}
var payload map[string]any
if len(body) > 0 {
if err := json.Unmarshal(body, &payload); err != nil {
return InboundReceiveResult{}, fmt.Errorf("invalid JSON body: %w", err)
}
}
_ = s.repo.Queries().TouchInboundHook(ctx, hook.ID)
switch hook.Mode {
case InboundModeJob:
if s.scheduler == nil {
return InboundReceiveResult{}, errors.New("scheduler unavailable")
}
if !hook.JobID.Valid {
return InboundReceiveResult{}, errors.New("hook has no job configured")
}
job, err := s.repo.Queries().GetScheduledJobByID(ctx, hook.JobID.Bytes)
if err != nil {
return InboundReceiveResult{}, err
}
if payload != nil {
raw, _ := json.Marshal(payload)
job.Payload = raw // received body replaces the configured payload
}
run, err := s.scheduler.enqueue(ctx, job, "hook")
if err != nil {
return InboundReceiveResult{}, err
}
return InboundReceiveResult{Received: true, Event: WebhookEventJobRun, RunID: run.ID.String()}, nil
case InboundModeFunction:
if s.functions == nil {
return InboundReceiveResult{}, errors.New("functions runtime not configured")
}
if !hook.FunctionID.Valid {
return InboundReceiveResult{}, errors.New("hook has no function configured")
}
fn, err := s.repo.Queries().GetFunction(ctx, uuid.UUID(hook.FunctionID.Bytes))
if err != nil {
return InboundReceiveResult{}, err
}
raw := body
if len(raw) == 0 {
raw = []byte(`{}`)
}
// Detached: the public endpoint acks immediately; the run row records
// the outcome. Hook callers must not wait on user code.
go func() {
runCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if _, err := s.executeFunction(runCtx, fn, raw, "hook"); err != nil && s.logger != nil {
s.logger.Warn("hook function invoke failed", "hook", hook.Name, "function", fn.Name, "error", err)
}
}()
return InboundReceiveResult{Received: true, Event: "function.invoked"}, nil
default:
s.publishEvent(ctx, hook.ProjectID, WebhookEventInboundReceived, map[string]any{
"hook_id": hook.ID.String(),
"hook_name": hook.Name,
"payload": payload,
})
return InboundReceiveResult{Received: true, Event: WebhookEventInboundReceived}, nil
}
}
func randHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
@@ -104,4 +104,39 @@ func TestValidateWebhookEvents(t *testing.T) {
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
t.Error("expected unknown event rejected")
}
for _, ok := range [][]string{
{"*"},
{"document.*"},
{"custom.deploy.done"},
{"custom.*"},
{"custom.deploy.*"},
} {
if _, err := validateWebhookEvents(ok); err != nil {
t.Errorf("expected %v accepted, got %v", ok, err)
}
}
for _, bad := range [][]string{
{"banana.*"}, {"custom..*"}, {"custom.UPPER"}, {"document.exploded"},
} {
if _, err := validateWebhookEvents(bad); err == nil {
t.Errorf("expected %v rejected", bad)
}
}
}
func TestPrivateWebhookHostHardening(t *testing.T) {
private := []string{"localhost", "minio", "echo-svc", "host.docker.internal",
"10.0.0.4", "172.16.5.5", "192.168.1.10", "fe80::1", "svc.local", "db.internal"}
public := []string{"169.254.169.254", "fd00:ec2::254", "2130706433", "0x7f000001",
"8.8.8.8", "example.com", "999999999999"}
for _, h := range private {
if !isPrivateWebhookHost(h) {
t.Errorf("expected %q private", h)
}
}
for _, h := range public {
if isPrivateWebhookHost(h) {
t.Errorf("expected %q public/blocked", h)
}
}
}
+663
View File
@@ -0,0 +1,663 @@
package services
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/robfig/cron/v3"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
)
// WebhookEventJobRun fires on every scheduled-job execution, successful or not.
const WebhookEventJobRun = "job.run"
// maxJobRunsPerJob bounds run history — older rows are pruned on each finish.
const maxJobRunsPerJob = 200
type ScheduledJobSummary struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Schedule string `json:"schedule"`
URL string `json:"url"`
FunctionID *string `json:"function_id,omitempty"`
Payload json.RawMessage `json:"payload"`
Enabled bool `json:"enabled"`
HasSecret bool `json:"has_secret"`
LastRunAt *time.Time `json:"last_run_at"`
LastStatus *string `json:"last_status"`
NextRunAt *time.Time `json:"next_run_at"`
CreatedAt time.Time `json:"created_at"`
// Secret is populated only when Primora generated it — one chance to see it.
Secret string `json:"secret,omitempty"`
}
type ScheduledJobRunSummary struct {
ID uuid.UUID `json:"id"`
JobID uuid.UUID `json:"job_id"`
Status string `json:"status"`
TriggeredBy string `json:"triggered_by"`
StatusCode *int32 `json:"status_code"`
Error string `json:"error"`
DurationMs *int32 `json:"duration_ms"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
type CreateScheduledJobInput struct {
Name string `json:"name" validate:"required,min=2"`
Schedule string `json:"schedule" validate:"required"`
URL string `json:"url" validate:"required_without=FunctionID,omitempty,url"`
FunctionID string `json:"function_id" validate:"omitempty,uuid"`
Secret string `json:"secret"`
Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
}
type UpdateScheduledJobInput struct {
Name *string `json:"name" validate:"omitempty,min=2"`
Schedule *string `json:"schedule"`
URL *string `json:"url" validate:"omitempty,url"`
FunctionID *string `json:"function_id" validate:"omitempty,uuid"`
Secret *string `json:"secret"`
Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
}
func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
out := ScheduledJobSummary{
ID: row.ID,
ProjectID: row.ProjectID,
Name: row.Name,
Schedule: row.Schedule,
URL: row.Url,
Payload: row.Payload,
Enabled: row.Enabled,
HasSecret: len(row.Secret) > 0,
CreatedAt: row.CreatedAt.Time,
}
if row.FunctionID.Valid {
id := uuid.UUID(row.FunctionID.Bytes).String()
out.FunctionID = &id
}
if row.LastRunAt.Valid {
t := row.LastRunAt.Time
out.LastRunAt = &t
}
if row.LastStatus != "" {
s := row.LastStatus
out.LastStatus = &s
}
if row.NextRunAt.Valid {
t := row.NextRunAt.Time
out.NextRunAt = &t
}
return out
}
func toJobRunSummary(row db.CoreScheduledJobRun) ScheduledJobRunSummary {
out := ScheduledJobRunSummary{
ID: row.ID,
JobID: row.JobID,
Status: row.Status,
TriggeredBy: row.TriggeredBy,
StatusCode: row.StatusCode,
Error: row.Error,
DurationMs: row.DurationMs,
StartedAt: row.CreatedAt.Time,
}
if row.FinishedAt.Valid {
t := row.FinishedAt.Time
out.FinishedAt = &t
}
return out
}
// parseSchedule accepts standard 5-field cron expressions plus robfig
// descriptors (@hourly, @every 30m, …).
func parseSchedule(expr string) (cron.Schedule, error) {
expr = strings.TrimSpace(expr)
if expr == "" {
return nil, errors.New("schedule is required")
}
sched, err := cron.ParseStandard(expr)
if err != nil {
return nil, fmt.Errorf("invalid schedule %q — expected a cron expression like \"*/15 * * * *\" or \"@every 1h\"", expr)
}
return sched, nil
}
func nextRunAt(sched cron.Schedule, from time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: sched.Next(from), Valid: true}
}
func (s *PlatformService) jobFor(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, write bool) (db.CoreScheduledJob, error) {
roles := []string{"admin", "developer", "viewer"}
if write {
roles = []string{"admin", "developer"}
}
if err := s.requireProjectRole(ctx, actor, projectID, roles...); err != nil {
return db.CoreScheduledJob{}, err
}
row, err := s.repo.Queries().GetScheduledJobByID(ctx, jobID)
if err != nil {
return db.CoreScheduledJob{}, err
}
if row.ProjectID != projectID {
return db.CoreScheduledJob{}, fmt.Errorf("job access denied")
}
return row, nil
}
func (s *PlatformService) ListScheduledJobs(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ScheduledJobSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListScheduledJobs(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]ScheduledJobSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toJobSummary(row))
}
return out, nil
}
func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateScheduledJobInput, requestID string) (ScheduledJobSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return ScheduledJobSummary{}, err
}
jobURL := ""
var functionID pgtype.UUID
if input.FunctionID != "" {
fid, err := uuid.Parse(input.FunctionID)
if err != nil {
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
}
fn, err := s.repo.Queries().GetFunction(ctx, fid)
if err != nil || fn.ProjectID != projectID {
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
}
functionID = pgtype.UUID{Bytes: fid, Valid: true}
jobURL = "function://" + fid.String()
} else {
var err error
jobURL, err = normalizeWebhookURL(input.URL)
if err != nil {
return ScheduledJobSummary{}, err
}
}
sched, err := parseSchedule(input.Schedule)
if err != nil {
return ScheduledJobSummary{}, err
}
secret := strings.TrimSpace(input.Secret)
generated := false
if secret == "" {
if secret, err = randomToken(16); err != nil {
return ScheduledJobSummary{}, err
}
generated = true
}
secretBytes, err := s.enc.Encrypt([]byte(secret))
if err != nil {
return ScheduledJobSummary{}, err
}
payload := []byte("{}")
if input.Payload != nil {
payload, _ = json.Marshal(input.Payload)
}
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
var createdBy pgtype.UUID
if actor.UserID != nil {
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
}
var next pgtype.Timestamptz
if enabled {
next = nextRunAt(sched, time.Now())
}
row, err := s.repo.Queries().CreateScheduledJob(ctx, db.CreateScheduledJobParams{
ProjectID: projectID,
Name: strings.TrimSpace(input.Name),
Schedule: strings.TrimSpace(input.Schedule),
Url: jobURL,
Secret: secretBytes,
Payload: payload,
Enabled: enabled,
NextRunAt: next,
CreatedByUserID: createdBy,
FunctionID: functionID,
})
if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
return ScheduledJobSummary{}, fmt.Errorf("a job named %q already exists", input.Name)
}
return ScheduledJobSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.created", "scheduled_job", row.ID.String(), map[string]any{
"name": row.Name, "schedule": row.Schedule, "url": row.Url,
}))
}
summary := toJobSummary(row)
if generated {
summary.Secret = secret
}
return summary, nil
}
func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, input UpdateScheduledJobInput, requestID string) (ScheduledJobSummary, error) {
row, err := s.jobFor(ctx, actor, projectID, jobID, true)
if err != nil {
return ScheduledJobSummary{}, err
}
name := row.Name
if input.Name != nil {
name = strings.TrimSpace(*input.Name)
}
schedule := row.Schedule
if input.Schedule != nil {
schedule = strings.TrimSpace(*input.Schedule)
}
jobURL := row.Url
if input.URL != nil {
if jobURL, err = normalizeWebhookURL(*input.URL); err != nil {
return ScheduledJobSummary{}, err
}
}
rotateFunction := input.FunctionID != nil
functionID := row.FunctionID
if rotateFunction {
raw := strings.TrimSpace(*input.FunctionID)
if raw == "" {
functionID = pgtype.UUID{} // explicit clear → back to URL delivery
if input.URL == nil && strings.HasPrefix(jobURL, "function://") {
return ScheduledJobSummary{}, fmt.Errorf("clearing function_id requires a url")
}
} else {
fid, ferr := uuid.Parse(raw)
if ferr != nil {
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
}
fn, ferr := s.repo.Queries().GetFunction(ctx, fid)
if ferr != nil || fn.ProjectID != projectID {
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
}
functionID = pgtype.UUID{Bytes: fid, Valid: true}
jobURL = "function://" + fid.String()
}
}
enabled := row.Enabled
if input.Enabled != nil {
enabled = *input.Enabled
}
sched, err := parseSchedule(schedule)
if err != nil {
return ScheduledJobSummary{}, err
}
var next pgtype.Timestamptz
if enabled {
next = nextRunAt(sched, time.Now())
}
rotateSecret := input.Secret != nil
var secretBytes []byte
if rotateSecret {
secret := strings.TrimSpace(*input.Secret)
if secret == "" {
if secret, err = randomToken(16); err != nil {
return ScheduledJobSummary{}, err
}
}
if secretBytes, err = s.enc.Encrypt([]byte(secret)); err != nil {
return ScheduledJobSummary{}, err
}
}
rotatePayload := input.Payload != nil
var payload []byte
if rotatePayload {
payload, _ = json.Marshal(input.Payload)
}
updated, err := s.repo.Queries().UpdateScheduledJob(ctx, db.UpdateScheduledJobParams{
ID: jobID,
ProjectID: projectID,
Name: name,
Schedule: schedule,
Url: jobURL,
Enabled: enabled,
NextRunAt: next,
Column8: rotateSecret,
Secret: secretBytes,
Column10: rotatePayload,
Payload: payload,
Column12: rotateFunction,
FunctionID: functionID,
})
if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
return ScheduledJobSummary{}, fmt.Errorf("a job named %q already exists", name)
}
return ScheduledJobSummary{}, err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.updated", "scheduled_job", jobID.String(), map[string]any{
"name": updated.Name, "schedule": updated.Schedule, "url": updated.Url, "enabled": updated.Enabled,
}))
summary := toJobSummary(updated)
if rotateSecret && strings.TrimSpace(*input.Secret) == "" {
if plain, decErr := s.enc.Decrypt(updated.Secret); decErr == nil {
summary.Secret = string(plain)
}
}
return summary, nil
}
func (s *PlatformService) DeleteScheduledJob(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, requestID string) error {
row, err := s.jobFor(ctx, actor, projectID, jobID, true)
if err != nil {
return err
}
if _, err := s.repo.Queries().DeleteScheduledJob(ctx, db.DeleteScheduledJobParams{
ID: jobID,
ProjectID: projectID,
}); err != nil {
return err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.deleted", "scheduled_job", jobID.String(), map[string]any{
"name": row.Name,
}))
return nil
}
func (s *PlatformService) ListScheduledJobRuns(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, limit int32) ([]ScheduledJobRunSummary, error) {
if _, err := s.jobFor(ctx, actor, projectID, jobID, false); err != nil {
return nil, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.repo.Queries().ListScheduledJobRuns(ctx, db.ListScheduledJobRunsParams{
JobID: jobID,
Limit: limit,
})
if err != nil {
return nil, err
}
out := make([]ScheduledJobRunSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toJobRunSummary(row))
}
return out, nil
}
// RunScheduledJob queues an out-of-band manual run. The run row is returned
// in "running" state; the result lands asynchronously.
func (s *PlatformService) RunScheduledJob(ctx context.Context, actor *models.Actor, projectID, jobID uuid.UUID, requestID string) (ScheduledJobRunSummary, error) {
job, err := s.jobFor(ctx, actor, projectID, jobID, true)
if err != nil {
return ScheduledJobRunSummary{}, err
}
if s.scheduler == nil {
return ScheduledJobRunSummary{}, errors.New("scheduler unavailable")
}
run, err := s.scheduler.enqueue(ctx, job, "manual")
if err != nil {
return ScheduledJobRunSummary{}, err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "job.run", "scheduled_job", jobID.String(), map[string]any{
"name": job.Name, "run_id": run.ID.String(),
}))
return toJobRunSummary(run), nil
}
// ---------------------------------------------------------------------------
// JobScheduler — in-process ticker, signed POST per due job, run history.
// ---------------------------------------------------------------------------
// jobQueries is the slice of db.Queries the scheduler needs — narrow so tests
// can stub it.
type jobQueries interface {
ListDueScheduledJobs(ctx context.Context, now pgtype.Timestamptz) ([]db.CoreScheduledJob, error)
InsertScheduledJobRun(ctx context.Context, params db.InsertScheduledJobRunParams) (db.CoreScheduledJobRun, error)
FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error)
MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error)
PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]db.ListProjectSecretValuesRow, error)
}
type JobScheduler struct {
repo jobQueries
enc *secrets.Encryptor
logger *slog.Logger
http *http.Client
interval time.Duration
queue chan jobExec
quit chan struct{}
wg sync.WaitGroup
started sync.Once
leader *leaderState
// onEvent fans a finished run out to webhooks and the realtime stream.
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
// runFn delivers to a function instead of HTTP when job.FunctionID is set.
// The function receives the resolved job payload, not the run envelope.
// Owned by PlatformService — the scheduler stays transport-agnostic.
runFn func(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error)
}
type jobExec struct {
job db.CoreScheduledJob
run db.CoreScheduledJobRun
}
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), pool *pgxpool.Pool) *JobScheduler {
if logger == nil {
logger = slog.Default()
}
return &JobScheduler{
repo: q,
enc: enc,
logger: logger,
http: &http.Client{Timeout: 10 * time.Second},
interval: 15 * time.Second,
queue: make(chan jobExec, 256),
quit: make(chan struct{}),
onEvent: onEvent,
leader: newLeaderState(pool, leaderKeyScheduler),
}
}
func (j *JobScheduler) Start(ctx context.Context) {
j.started.Do(func() {
j.wg.Add(1)
go j.run(ctx)
})
}
func (j *JobScheduler) Stop() {
close(j.quit)
j.wg.Wait()
}
func (j *JobScheduler) run(ctx context.Context) {
defer j.wg.Done()
ticker := time.NewTicker(j.interval)
defer ticker.Stop()
for {
select {
case exec := <-j.queue:
j.execute(exec.job, exec.run)
case <-ticker.C:
// Only the leader fires scheduled runs — manual/hook enqueues are
// already scoped to whichever replica received the request.
if j.leader.hold(ctx) {
j.scan()
}
case <-j.quit:
j.leader.stop(context.Background())
return
case <-ctx.Done():
return
}
}
}
// scan picks up enabled jobs whose next_run_at has passed. Missed occurrences
// collapse into a single run — Primora fires once, not once per missed tick.
func (j *JobScheduler) scan() {
ctx := context.Background()
jobs, err := j.repo.ListDueScheduledJobs(ctx, pgtype.Timestamptz{Time: time.Now(), Valid: true})
if err != nil {
j.logger.Warn("scheduled job scan failed", "error", err)
return
}
for _, job := range jobs {
run, err := j.repo.InsertScheduledJobRun(ctx, db.InsertScheduledJobRunParams{JobID: job.ID, TriggeredBy: "schedule"})
if err != nil {
j.logger.Warn("job run insert failed", "job_id", job.ID, "error", err)
j.markRan(ctx, job, "failed")
continue
}
j.execute(job, run)
}
}
// enqueue inserts the run row synchronously so callers get a run ID, then
// hands the execution to the worker.
func (j *JobScheduler) enqueue(ctx context.Context, job db.CoreScheduledJob, triggeredBy string) (db.CoreScheduledJobRun, error) {
run, err := j.repo.InsertScheduledJobRun(ctx, db.InsertScheduledJobRunParams{
JobID: job.ID,
TriggeredBy: triggeredBy,
})
if err != nil {
return db.CoreScheduledJobRun{}, err
}
select {
case j.queue <- jobExec{job: job, run: run}:
case <-j.quit:
default:
j.logger.Warn("job queue full, run dropped", "job_id", job.ID, "run_id", run.ID)
}
return run, nil
}
func (j *JobScheduler) execute(job db.CoreScheduledJob, run db.CoreScheduledJobRun) {
ctx := context.Background()
start := time.Now()
statusCode, sendErr := j.deliver(ctx, job, run)
status := "success"
errText := ""
if sendErr != nil {
status = "failed"
errText = sendErr.Error()
}
duration := int32(time.Since(start).Milliseconds())
_, _ = j.repo.FinishScheduledJobRun(ctx, db.FinishScheduledJobRunParams{
ID: run.ID,
Status: status,
StatusCode: statusCode,
Error: errText,
DurationMs: &duration,
})
j.markRan(ctx, job, status)
_ = j.repo.PruneScheduledJobRuns(ctx, db.PruneScheduledJobRunsParams{JobID: job.ID, Limit: maxJobRunsPerJob})
if j.onEvent != nil {
j.onEvent(ctx, job.ProjectID, WebhookEventJobRun, map[string]any{
"job_id": job.ID.String(),
"job_name": job.Name,
"run_id": run.ID.String(),
"triggered_by": run.TriggeredBy,
"status": status,
"status_code": statusCode,
"error": errText,
"duration_ms": duration,
})
}
}
func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, status string) {
var next pgtype.Timestamptz
if job.Enabled {
if sched, err := parseSchedule(job.Schedule); err == nil {
next = nextRunAt(sched, time.Now())
}
}
if _, err := j.repo.MarkScheduledJobRan(ctx, db.MarkScheduledJobRanParams{
ID: job.ID,
LastRunAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastStatus: status,
NextRunAt: next,
}); err != nil {
j.logger.Warn("job mark-ran failed", "job_id", job.ID, "error", err)
}
}
// deliver POSTs the job payload to its URL — or invokes the target function
// when the job carries a function_id.
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload)
if err != nil {
return nil, err
}
payload, _ := json.Marshal(map[string]any{
"event": WebhookEventJobRun,
"project_id": job.ProjectID.String(),
"job_id": job.ID.String(),
"job_name": job.Name,
"run_id": run.ID.String(),
"triggered_by": run.TriggeredBy,
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": data,
})
if job.FunctionID.Valid {
if j.runFn == nil {
return nil, fmt.Errorf("functions runtime not configured")
}
return j.runFn(ctx, job, run, data)
}
secret := ""
if len(job.Secret) > 0 && j.enc != nil {
if plain, err := j.enc.Decrypt(job.Secret); err == nil {
secret = string(plain)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, job.Url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Primora-Event", WebhookEventJobRun)
req.Header.Set("X-Primora-Job", job.ID.String())
req.Header.Set("X-Primora-Run", run.ID.String())
req.Header.Set("X-Primora-Signature", "sha256="+signBody(secret, payload))
resp, err := j.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
code := int32(resp.StatusCode)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &code, fmt.Errorf("endpoint returned %d", resp.StatusCode)
}
return &code, nil
}
+98
View File
@@ -0,0 +1,98 @@
package services
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// Leader election via Postgres advisory locks. The lock is session-scoped, so
// the lease pins its pool connection for its entire lifetime — a handoff back
// to the pool would silently drop it. A single-replica deployment simply
// always holds the lock; there is no feature flag.
//
// Distinct keys per component so the job scheduler and the alert evaluator
// elect independently.
const (
leaderKeyScheduler int64 = 728201
leaderKeyAlerts int64 = 728202
)
type leaderLease struct {
conn *pgxpool.Conn
key int64
}
// acquireLeadership returns a lease when this process wins the lock, nil when
// another replica holds it, or an error when the check itself failed.
func acquireLeadership(ctx context.Context, pool *pgxpool.Pool, key int64) (*leaderLease, error) {
conn, err := pool.Acquire(ctx)
if err != nil {
return nil, err
}
var got bool
if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&got); err != nil {
conn.Release()
return nil, err
}
if !got {
conn.Release()
return nil, nil
}
return &leaderLease{conn: conn, key: key}, nil
}
// alive pings the pinned connection — a dead conn means Postgres dropped the
// session and released the lock, so leadership is lost even though the lease
// object still exists.
func (l *leaderLease) alive(ctx context.Context) bool {
var one int
return l.conn.QueryRow(ctx, "SELECT 1").Scan(&one) == nil
}
func (l *leaderLease) release(ctx context.Context) {
_, _ = l.conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", l.key)
l.conn.Release()
}
// leaderState tracks a lease inside a ticker loop — acquire when absent,
// verify when held, drop and re-elect when the connection dies.
type leaderState struct {
pool *pgxpool.Pool
key int64
lease *leaderLease
}
func newLeaderState(pool *pgxpool.Pool, key int64) *leaderState {
return &leaderState{pool: pool, key: key}
}
// hold reports whether this process currently leads. A nil pool means no
// election configured (tests, embedded use) — always lead.
func (l *leaderState) hold(ctx context.Context) bool {
if l == nil || l.pool == nil {
return true
}
if l.lease != nil {
if l.lease.alive(ctx) {
return true
}
l.lease.release(ctx)
l.lease = nil
}
lease, err := acquireLeadership(ctx, l.pool, l.key)
if err != nil {
return false
}
l.lease = lease
return lease != nil
}
func (l *leaderState) stop(ctx context.Context) {
if l != nil && l.lease != nil {
l.lease.release(ctx)
l.lease = nil
}
}
+27 -3
View File
@@ -11,6 +11,9 @@ import (
"github.com/tdvorak/primora/apps/backend/internal/config"
)
// Mailer is transport-only: Resend when configured, plain SMTP otherwise.
// Templates render (subject, body) from data; sends are logged to
// core.email_log by the service layer so operators can see what went out.
type Mailer struct {
cfg config.Config
resend *resend.Client
@@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer {
return &Mailer{cfg: cfg, resend: resendClient}
}
func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error {
subject := fmt.Sprintf("You were invited to %s on Primora", organizationName)
text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n"
// mailTemplate renders a subject + plaintext body from template data.
type mailTemplate func(data map[string]string) (subject string, body string)
var mailTemplates = map[string]mailTemplate{
"invitation": func(d map[string]string) (string, string) {
subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"])
body := fmt.Sprintf(
"You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n",
d["invite_url"],
)
return subject, body
},
}
func renderMailTemplate(template string, data map[string]string) (string, string, error) {
tpl, ok := mailTemplates[template]
if !ok {
return "", "", fmt.Errorf("unknown email template %q", template)
}
subject, body := tpl(data)
return subject, body, nil
}
func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error {
if m.resend != nil {
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
From: m.cfg.MailFrom,
@@ -0,0 +1,37 @@
package services
import (
"strings"
"testing"
)
func TestRenderMailTemplateInvitation(t *testing.T) {
subject, body, err := renderMailTemplate("invitation", map[string]string{
"organization": "Acme Corp",
"invite_url": "https://app.primora.dev/accept?token=abc",
})
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(subject, "Acme Corp") {
t.Fatalf("subject missing organization: %q", subject)
}
if !strings.Contains(body, "https://app.primora.dev/accept?token=abc") {
t.Fatalf("body missing invite url: %q", body)
}
}
func TestRenderMailTemplateUnknown(t *testing.T) {
if _, _, err := renderMailTemplate("does-not-exist", nil); err == nil {
t.Fatal("expected error for unknown template")
}
}
func TestExtractEmail(t *testing.T) {
if got := extractEmail("Primora <[email protected]>"); got != "[email protected]" {
t.Fatalf("extractEmail = %q", got)
}
if got := extractEmail("[email protected]"); got != "[email protected]" {
t.Fatalf("extractEmail bare = %q", got)
}
}
@@ -10,9 +10,12 @@ import (
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/google/uuid"
@@ -29,14 +32,22 @@ import (
type PlatformService struct {
repo *repositories.CoreRepository
store *storage.LocalStore
store storage.Store
mailer *Mailer
publicURL string
dbx *dbx.Client
managedDBSeeds []ManagedDBSeed
hub *EventHub
realtime *EventHub
enc *secrets.Encryptor
dispatcher *WebhookDispatcher
scheduler *JobScheduler
alerts *alertEvaluator
functions FunctionRunner
logger *slog.Logger
// presenceMu guards presence: realtime subscriber counts per project.
presenceMu sync.Mutex
presence map[uuid.UUID]int
}
type BootstrapInput struct {
@@ -67,6 +78,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 +94,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 +233,221 @@ 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.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *PlatformService {
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions, logger: logger, presence: map[uuid.UUID]int{}}
if enc != nil {
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
s.dispatcher.Start(context.Background())
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent, repo.Pool())
s.scheduler.runFn = s.runJobFunction
s.scheduler.Start(context.Background())
s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool())
s.alerts.Start(context.Background())
}
return s
}
// Close stops the webhook dispatcher. Idempotent.
// sendTemplatedEmail renders a registered template, sends it, and writes an
// email_log row with the outcome — failed sends are still logged so the
// dashboard shows them.
func (s *PlatformService) sendTemplatedEmail(ctx context.Context, projectID *uuid.UUID, template, to string, data map[string]string) error {
subject, body, err := renderMailTemplate(template, data)
status, errText := "sent", ""
if err == nil {
if sendErr := s.mailer.Send(ctx, to, subject, body); sendErr != nil {
status, errText, err = "failed", sendErr.Error(), sendErr
}
} else {
status, errText = "failed", err.Error()
}
var pid pgtype.UUID
if projectID != nil {
pid = pgtype.UUID{Bytes: *projectID, Valid: true}
}
_, _ = s.repo.Queries().InsertEmailLog(ctx, db.InsertEmailLogParams{
ProjectID: pid,
Template: template,
ToEmail: to,
Subject: subject,
Status: status,
Error: errText,
})
return err
}
type EmailLogSummary struct {
ID string `json:"id"`
Template string `json:"template"`
ToEmail string `json:"to_email"`
Subject string `json:"subject"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func (s *PlatformService) ListEmailLog(ctx context.Context, actor *models.Actor, projectID uuid.UUID, limit int64) ([]EmailLogSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.repo.Queries().ListEmailLog(ctx, db.ListEmailLogParams{ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, Limit: int32(limit)})
if err != nil {
return nil, err
}
out := make([]EmailLogSummary, len(rows))
for i, r := range rows {
out[i] = EmailLogSummary{
ID: r.ID.String(),
Template: r.Template,
ToEmail: r.ToEmail,
Subject: r.Subject,
Status: r.Status,
Error: r.Error,
CreatedAt: r.CreatedAt.Time,
}
}
return out, nil
}
// Close stops the webhook dispatcher and job scheduler. Idempotent.
func (s *PlatformService) Close() {
if s.dispatcher != nil {
s.dispatcher.Stop()
}
if s.scheduler != nil {
s.scheduler.Stop()
}
if s.alerts != nil {
s.alerts.Stop()
}
}
// publishEvent fans a domain event out to realtime SSE subscribers and to
// matching webhooks. Failures are logged by the callees — events never block
// the request that produced them.
func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
if s.realtime != nil {
s.realtime.Broadcast(projectID.String(), map[string]any{
"type": eventType,
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": data,
})
}
s.dispatchEvent(ctx, projectID, eventType, data)
s.triggerFunctionsForEvent(ctx, projectID, eventType, data)
}
// SubscribeRealtime registers an SSE consumer for project domain events and
// bumps the project's presence count.
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
ch := s.realtime.Subscribe(projectID.String())
s.bumpPresence(projectID, 1)
return ch, nil
}
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
s.realtime.Unsubscribe(projectID.String(), ch)
s.bumpPresence(projectID, -1)
}
// bumpPresence adjusts the online count and broadcasts presence.update to
// realtime subscribers only — deliberately not through publishEvent, so
// webhooks and `event_pattern: "*"` functions don't fire on connect noise.
func (s *PlatformService) bumpPresence(projectID uuid.UUID, delta int) {
s.presenceMu.Lock()
n := s.presence[projectID] + delta
if n <= 0 {
delete(s.presence, projectID)
n = 0
} else {
s.presence[projectID] = n
}
s.presenceMu.Unlock()
s.realtime.Broadcast(projectID.String(), map[string]any{
"type": "presence.update",
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": map[string]any{"online": n},
})
}
// Presence returns the current realtime subscriber count for a project.
func (s *PlatformService) Presence(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (int, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return 0, err
}
s.presenceMu.Lock()
defer s.presenceMu.Unlock()
return s.presence[projectID], nil
}
var customEventTypeRe = regexp.MustCompile(`^custom\.[a-z0-9][a-z0-9_.-]{0,62}$`)
// PublishProjectEvent lets a member emit a custom domain event — realtime
// subscribers, matching webhooks, and event_pattern functions all fire via
// the standard publishEvent fan-out. The custom.* prefix is enforced so
// clients can't spoof system event types (document.created etc.).
func (s *PlatformService) PublishProjectEvent(ctx context.Context, actor *models.Actor, projectID uuid.UUID, eventType string, data map[string]any) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return err
}
if !customEventTypeRe.MatchString(eventType) {
return errors.New("invalid event type: must match custom.<name> (lowercase letters, digits, dots, dashes)")
}
if data == nil {
data = map[string]any{}
}
s.publishEvent(ctx, projectID, eventType, data)
return nil
}
// ActorContext describes the caller's own scope — the self-discovery
// endpoint for API keys, which cannot call /me. User actors get just the
// type marker; their full view is PlatformSummary.
type ActorContext struct {
Actor string `json:"actor"`
Scopes []string `json:"scopes,omitempty"`
KeyPrefix string `json:"key_prefix,omitempty"`
Organization *ContextOrgInfo `json:"organization,omitempty"`
Project *ContextRef `json:"project,omitempty"`
}
type ContextOrgInfo struct {
ContextRef
Role string `json:"role,omitempty"`
}
type ContextRef struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
}
func (s *PlatformService) ActorContext(ctx context.Context, actor *models.Actor) (ActorContext, error) {
out := ActorContext{Actor: string(actor.Type)}
if actor.Type != models.ActorTypeAPIKey {
return out, nil
}
out.Scopes = actor.Scopes
out.KeyPrefix = actor.APIKeyPrefix
if actor.ProjectID == nil {
return out, nil
}
project, err := s.repo.Queries().GetProjectByID(ctx, *actor.ProjectID)
if err != nil {
return out, err
}
out.Project = &ContextRef{ID: project.ID, Slug: project.Slug, Name: project.Name}
org, err := s.repo.Queries().GetOrganizationByID(ctx, project.OrganizationID)
if err != nil {
return out, err
}
out.Organization = &ContextOrgInfo{ContextRef: ContextRef{ID: org.ID, Slug: org.Slug, Name: org.Name}}
return out, nil
}
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
@@ -736,10 +954,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
@@ -866,7 +1087,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac
if err != nil {
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
}
if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil {
var inviteProject *uuid.UUID
if projectUUID.Valid {
id := uuid.UUID(projectUUID.Bytes)
inviteProject = &id
}
if err := s.sendTemplatedEmail(ctx, inviteProject, "invitation", invitation.Email, map[string]string{
"organization": "Primora",
"invite_url": inviteURL,
}); err != nil {
return nil, err
}
return map[string]any{
@@ -1038,6 +1267,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 +1276,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 +1293,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 +1463,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
}
@@ -1269,6 +1524,64 @@ func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bu
return object, file, nil
}
// ErrPresignUnsupported — the configured storage driver can't mint signed
// URLs (only the S3 driver can). Handlers map it to 400, not 500.
var ErrPresignUnsupported = errors.New("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
// InputError marks a service-layer failure caused by caller input — the
// HTTP layer maps it to 400 instead of 500.
type InputError struct{ msg string }
func (e *InputError) Error() string { return e.msg }
func NewInputError(msg string) error { return &InputError{msg: msg} }
func inputErrorf(format string, args ...any) error {
return &InputError{msg: fmt.Sprintf(format, args...)}
}
// PresignObject mints a short-lived direct-to-storage URL for a single
// object. Requires the s3 storage driver — the local store has no external
// URL surface to sign against.
func (s *PlatformService) PresignObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, op string, ttlSeconds int) (*storage.PresignedURL, error) {
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return nil, err
}
method := ""
switch op {
case "download", "":
method = http.MethodGet
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
return nil, err
}
if !objectExists(ctx, s, bucketID, objectKey) {
return nil, pgx.ErrNoRows
}
case "upload":
method = http.MethodPut
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("invalid op %q — download or upload", op)
}
p, ok := s.store.(storage.Presigner)
if !ok {
return nil, ErrPresignUnsupported
}
ttl := time.Duration(ttlSeconds) * time.Second
return p.Presign(ctx, bucketID.String(), objectKey, method, ttl)
}
func objectExists(ctx context.Context, s *PlatformService, bucketID uuid.UUID, key string) bool {
_, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
BucketID: bucketID,
ObjectKey: key,
})
return err == nil
}
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
@@ -1335,6 +1648,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 +1735,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 +1768,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 +1995,11 @@ func (s *PlatformService) CreateDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.created", "document", row.ID.String(), map[string]any{
"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 +2024,11 @@ func (s *PlatformService) UpdateDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.updated", "document", documentID.String(), map[string]any{
"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 +2050,10 @@ func (s *PlatformService) DeleteDocument(ctx context.Context, actor *models.Acto
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.deleted", "document", documentID.String(), map[string]any{
"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,234 @@
package services
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
)
// Project secrets are the server-side counterpart of the CLI's local vault:
// AES-256-GCM at rest, metadata-only listings, audited reveal. Job payloads
// reference them as secret://NAME — resolved at delivery, never stored in
// plaintext config.
var projectSecretNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type ProjectSecretSummary struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type SetProjectSecretInput struct {
Name string `json:"name"` // from the path param, not the body
Value string `json:"value" validate:"required"`
URL *string `json:"url"`
Notes *string `json:"notes"`
}
type RevealProjectSecretResponse struct {
Name string `json:"name"`
Value string `json:"value"`
}
func toProjectSecretSummary(row db.ListProjectSecretsRow) ProjectSecretSummary {
return ProjectSecretSummary{
ID: row.ID,
Name: row.Name,
URL: row.Url,
Notes: row.Notes,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
func (s *PlatformService) ListProjectSecrets(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ProjectSecretSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListProjectSecrets(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]ProjectSecretSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toProjectSecretSummary(row))
}
return out, nil
}
func (s *PlatformService) SetProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input SetProjectSecretInput, requestID string) (ProjectSecretSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return ProjectSecretSummary{}, err
}
name := strings.TrimSpace(input.Name)
if !projectSecretNameRe.MatchString(name) {
return ProjectSecretSummary{}, fmt.Errorf("invalid secret name — use env-var-safe names [A-Za-z_][A-Za-z0-9_]*")
}
if s.enc == nil {
return ProjectSecretSummary{}, fmt.Errorf("encryption is not configured on this deployment")
}
ciphertext, err := s.enc.Encrypt([]byte(input.Value))
if err != nil {
return ProjectSecretSummary{}, err
}
// Merge url/notes over the existing row when the caller omits them —
// `secrets set NAME` should not wipe metadata it never saw.
var url, notes string
if existing, err := s.repo.Queries().GetProjectSecret(ctx, db.GetProjectSecretParams{ProjectID: projectID, Name: name}); err == nil {
url, notes = existing.Url, existing.Notes
} else if err != pgx.ErrNoRows {
return ProjectSecretSummary{}, err
}
if input.URL != nil {
url = strings.TrimSpace(*input.URL)
}
if input.Notes != nil {
notes = strings.TrimSpace(*input.Notes)
}
row, err := s.repo.Queries().UpsertProjectSecret(ctx, db.UpsertProjectSecretParams{
ProjectID: projectID,
Name: name,
Ciphertext: ciphertext,
Url: url,
Notes: notes,
})
if err != nil {
return ProjectSecretSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.set", "project_secret", row.ID.String(), map[string]any{
"name": row.Name,
}))
}
return ProjectSecretSummary{
ID: row.ID,
Name: row.Name,
URL: row.Url,
Notes: row.Notes,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}, nil
}
func (s *PlatformService) DeleteProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name string, requestID string) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return err
}
row, err := s.repo.Queries().DeleteProjectSecret(ctx, db.DeleteProjectSecretParams{ProjectID: projectID, Name: name})
if err != nil {
return err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.deleted", "project_secret", row.String(), map[string]any{
"name": name,
}))
}
return nil
}
// Reveal decrypts a single secret. Deliberately loud: audit-logged every time.
func (s *PlatformService) RevealProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name string, requestID string) (RevealProjectSecretResponse, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return RevealProjectSecretResponse{}, err
}
row, err := s.repo.Queries().GetProjectSecret(ctx, db.GetProjectSecretParams{ProjectID: projectID, Name: name})
if err != nil {
return RevealProjectSecretResponse{}, err
}
plain, err := s.enc.Decrypt(row.Ciphertext)
if err != nil {
return RevealProjectSecretResponse{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.revealed", "project_secret", row.ID.String(), map[string]any{
"name": row.Name,
}))
}
return RevealProjectSecretResponse{Name: row.Name, Value: string(plain)}, nil
}
// resolveSecretRefs replaces "secret://NAME" string values in a payload with
// decrypted values. Names that do not resolve fail loudly — a literal
// secret:// string reaching the endpoint is worse than a failed run.
// Package-level so the JobScheduler can reuse it through its narrow repo iface.
func resolveSecretRefs(
ctx context.Context,
list func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error),
enc *secrets.Encryptor,
projectID uuid.UUID,
payload json.RawMessage,
) (json.RawMessage, error) {
if len(payload) == 0 || !strings.Contains(string(payload), "secret://") {
return payload, nil
}
if enc == nil {
return nil, fmt.Errorf("payload references project secrets but encryption is not configured on this deployment")
}
rows, err := list(ctx, projectID)
if err != nil {
return nil, err
}
values := make(map[string]string, len(rows))
for _, row := range rows {
plain, err := enc.Decrypt(row.Ciphertext)
if err != nil {
return nil, fmt.Errorf("project secret %q failed to decrypt", row.Name)
}
values[row.Name] = string(plain)
}
var doc any
if err := json.Unmarshal(payload, &doc); err != nil {
return nil, fmt.Errorf("payload is not valid JSON: %w", err)
}
var missing []string
resolved := walkSecretRefs(doc, values, &missing)
if len(missing) > 0 {
return nil, fmt.Errorf("unresolved secret references: %s", strings.Join(missing, ", "))
}
out, err := json.Marshal(resolved)
if err != nil {
return nil, err
}
return out, nil
}
func walkSecretRefs(node any, values map[string]string, missing *[]string) any {
switch v := node.(type) {
case string:
if strings.HasPrefix(v, "secret://") {
name := strings.TrimPrefix(v, "secret://")
if resolved, ok := values[name]; ok {
return resolved
}
*missing = append(*missing, name)
return v
}
return v
case map[string]any:
for k, item := range v {
v[k] = walkSecretRefs(item, values, missing)
}
return v
case []any:
for i, item := range v {
v[i] = walkSecretRefs(item, values, missing)
}
return v
default:
return node
}
}
Loaded 100 of 323 files, more files were not shown because too many files have changed in this diff. Show more