59 Commits
Author SHA1 Message Date
Tomáš DvořákandDevin 361538aabc chore(release): v0.8.4 (#49)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 21:56:16 +02:00
Tomáš DvořákandDevin 5eec80bbca feat(frontend): auto-derive slugs from names on create forms (#48)
The org/project/bucket/collection create forms required typing a slug
by hand. While the slug field is untouched (empty or still equal to the
derived value), naming the entity fills its slug; a manual slug edit
stops the derivation and is sanitized to slug form as you type.

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 21:53:27 +02:00
Tomáš DvořákandDevin 40c189c431 fix(install): stop exec from silencing the tty prompt (#47)
exec 9</dev/tty 2>/dev/null inside the if condition redirected the whole
shell's stderr to /dev/null permanently — the read -p prompt then wrote
into the void, leaving the installer blocked on an invisible question.
Replace with a plain </dev/tty redirect on read itself plus a quiet
true </dev/tty probe: the prompt displays, answers land, and no-tty
runs fall back to the default without error noise or the exec
redirect-failure shell-exit hazard.

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 21:39:54 +02:00
Tomáš DvořákandDevin bc8b165ad9 fix(install): fail fast when GitHub fetch stalls (#46)
curl without timeouts hangs silently on a stalled connection — the
install looked dead between 'Fetching compose files' and the image
pulls. --connect-timeout/--max-time/--retry-all-errors turn it into a
clear error naming the file.

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 21:14:15 +02:00
Tomáš Dvořák 61f0974c61 Merge pull request #45 from Dvorinka/release/v0.8.3
chore(release): v0.8.3
2026-09-28 20:40:17 +02:00
Tomas DvorakandDevin 769d04471f chore(release): v0.8.3
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 20:36:35 +02:00
Tomáš Dvořák da294aaa2b Merge pull request #44 from Dvorinka/fix/install-prompt-tty
fix(install): prompt on /dev/tty under curl|bash
2026-09-28 20:22:14 +02:00
Tomas DvorakandDevin 1226d44872 fix(install): prompt on /dev/tty so it works under curl|bash
stdin is the script pipe when run as `curl ... | bash`, so the -t 0
check silently skipped the domain question. Read from the controlling
terminal instead; no tty (CI/cron) still defaults to localhost.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 20:19:36 +02:00
Tomáš Dvořák ac4ca9a3ae Merge pull request #43 from Dvorinka/feat/install-public-url
feat(install): ask for the public URL on fresh installs
2026-09-28 19:34:27 +02:00
Tomas DvorakandDevin 597db3287e feat(install): ask for the public URL, wire it into .env
install.sh now asks one question on fresh installs — the URL the app is
opened on. Accepts https://primora.example.com (TLS at the user's proxy),
primora.example.com, or 192.168.1.50:8085; the port becomes NGINX_PORT.
The answer rewrites every public URL var and COOKIE_DOMAIN for real DNS
names. PUBLIC_URL env keeps it non-interactive; legacy DOMAIN/NGINX_PORT
still work; reruns over an existing .env skip the question and display
the URL from the file.

Also trust both URL schemes on the serving host in trustedOrigins —
external TLS termination makes nginx report http while browsers arrive
over https.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 19:31:54 +02:00
Tomáš Dvořák bbc57f9c0d Merge pull request #42 from Dvorinka/fix/sbom-permissions
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(release): contents:write for SBOM asset upload
2026-09-28 16:44:05 +02:00
Tomas DvorakandDevin 9ab9b550d1 fix(release): contents:write for SBOM asset upload
The sbom job's `permissions: packages: read` replaces the default token
set, so `gh release upload` got 403 Resource not accessible. Grant
contents:write alongside packages:read.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 16:41:17 +02:00
Tomáš Dvořák 49ca9067a9 Merge pull request #41 from Dvorinka/release/v0.8.2
chore(release): v0.8.2
2026-09-28 16:28:11 +02:00
Tomas DvorakandDevin 805a58e32f chore(release): v0.8.2
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 16:26:11 +02:00
Tomáš Dvořák d09b0a1c2d Merge pull request #40 from Dvorinka/fix/signup-bounce
fix(auth): sign-up bounce — surface errors, dynamic trusted origins, unbreakable mail probe
2026-09-28 16:25:24 +02:00
Tomas DvorakandDevin 92b1810c07 fix(auth): surface sign-up failures, trust serving origin, unbreakable mail probe
Self-hosted sign-up bounced back to the bootstrap screen for three
compounding reasons:

- better-auth's client resolves { data, error } on HTTP failure; the UI
  ignored res.error and showed "Account created — signing you in", hiding
  the real 403/429/500.
- trustedOrigins was a static localhost-only list. Browsing the instance
  via LAN IP or custom port failed every auth POST once cookies existed.
  Origins are now derived from the request's public host (forwarded by
  nginx), so any origin serving the app works.
- hasMailTransport threw when core.settings was unreadable (fresh volume,
  backend still migrating), so sign-up 500'd and the startup backfill
  could crash-loop the service. Both now degrade to "no transport".

Verified live: LAN-style origin sign-up succeeds, missing core.settings
logs a warn instead of exiting, and the frontend shows real errors.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 16:23:01 +02:00
Tomáš Dvořák 3ad37c190b Merge pull request #39 from Dvorinka/fix/sbom-output-dir
fix: create dist-release dir before SBOM output writes
2026-09-28 16:02:16 +02:00
Tomas DvorakandDevin cb56cedb1f fix: create dist-release dir before SBOM output writes
anchore/sbom-action does not create the output directory — the v0.8.1
run scanned images fine (lowercase owner works) but ENOENT'd writing
dist-release/sbom-*.cdx.json.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 16:00:00 +02:00
Tomáš Dvořák 20b4ea6a07 Merge pull request #38 from Dvorinka/release/v0.8.1
release: v0.8.1
2026-09-28 15:48:12 +02:00
Tomas DvorakandDevin 0511e5754b release: v0.8.1 — SMTP-less auth fix
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 15:45:54 +02:00
Tomáš Dvořák 3800e736d0 Merge pull request #37 from Dvorinka/fix/smtpless-auth-verification
fix: skip email verification entirely when no mail transport exists
2026-09-28 15:45:16 +02:00
Tomas DvorakandDevin 7548ea75d4 fix: skip email verification entirely when no mail transport exists
SMTP-less self-hosted instances still marked every account
emailVerified=false and attempted a verification mail that could
never arrive. Now: no transport → accounts are created verified,
the send is skipped, and existing unverified rows are backfilled
on startup. .env.example stops defaulting SMTP_HOST to the
dev-only mailpit service.

Verified live: fresh signup returns emailVerified=true, legacy
unverified users flip on restart, sign-in unaffected.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 15:43:26 +02:00
Tomáš Dvořák 14d0762245 Merge pull request #36 from Dvorinka/chore/gitignore-brag-output
chore: gitignore local brag-output directory
2026-09-28 15:29:31 +02:00
Tomas DvorakandDevin a990989d34 chore: gitignore local brag-output directory
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-28 15:27:21 +02:00
Tomáš Dvořák 3b9ea333ad Merge pull request #35 from Dvorinka/fix/sbom-ghcr-case
fix: lowercase GHCR owner in SBOM image refs
2026-09-28 13:55:59 +02:00
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
216 changed files with 15256 additions and 394 deletions

No files matched your search

+30 -1
View File
@@ -50,11 +50,40 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1
BACKEND_INTERNAL_URL=http://backend:8080 BACKEND_INTERNAL_URL=http://backend:8080
BACKEND_STORAGE_ROOT=/data/storage BACKEND_STORAGE_ROOT=/data/storage
# Object storage driver: "local" (default, uses BACKEND_STORAGE_ROOT) or "s3".
# For s3, point S3_ENDPOINT at any S3-compatible API (AWS, MinIO, Garage, R2).
BACKEND_STORAGE_DRIVER=local
#S3_ENDPOINT=http://minio:9000
#S3_REGION=us-east-1
#S3_BUCKET=primora-objects
#S3_ACCESS_KEY_ID=
#S3_SECRET_ACCESS_KEY=
#S3_PREFIX=
#S3_PATH_STYLE=true
# Client-facing S3 endpoint for presigned URLs — set when S3_ENDPOINT is an
# internal address (e.g. http://minio:9000 inside compose) that browsers
# can't reach. Presigned URLs are valid only at the host they were minted for.
#S3_PUBLIC_ENDPOINT=https://objects.example.com
# Functions execution. "exec" runs the runtime binary (bun/deno) directly on
# the backend host — simple, but function code inherits backend privileges.
# "docker" runs each invoke in an ephemeral locked-down container (no
# network, read-only fs, 128 MiB mem, non-root) and requires the Docker
# socket mounted into the backend plus the runtime images pulled.
#FUNCTIONS_DRIVER=exec
#FUNCTIONS_RUNTIME=auto
#FUNCTIONS_TIMEOUT_SECONDS=30
#FUNCTIONS_MAX_OUTPUT_BYTES=65536
# Mail defaults — each is overridable in-app (Settings → Instance). In-app # Mail defaults — each is overridable in-app (Settings → Instance). In-app
# values win over these env vars; deleting the in-app value reverts here. # values win over these env vars; deleting the in-app value reverts here.
# With no transport configured (no Resend key and no SMTP_HOST) accounts are
# verified automatically and no verification mail is attempted.
# The dev overlay (docker compose -f docker-compose.yml -f docker-compose.dev.yml)
# runs Mailpit — then SMTP_HOST=mailpit catches all outgoing mail at :8025.
RESEND_API_KEY= RESEND_API_KEY=
MAIL_FROM=Primora <[email protected]> MAIL_FROM=Primora <[email protected]>
SMTP_HOST=mailpit SMTP_HOST=
SMTP_PORT=1025 SMTP_PORT=1025
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
+143 -10
View File
@@ -59,17 +59,20 @@ jobs:
fi fi
images: images:
name: Build + push ${{ matrix.app }} name: Build + push ${{ matrix.app }} (${{ matrix.platform }})
needs: verify needs: verify
if: needs.verify.outputs.needed == 'true' if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest # Native runners per arch — QEMU emulation SIGILLs on the current
# node:20-alpine arm64 layers (uncaught target signal 4) and wedges
# the build step indefinitely.
runs-on: ${{ matrix.platform == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
app: [backend, auth, frontend] app: [backend, auth, frontend]
platform: [amd64, arm64]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
# ghcr.io requires lowercase repo paths; the owner login may be mixed-case. # ghcr.io requires lowercase repo paths; the owner login may be mixed-case.
- name: Compute lowercase GHCR owner - name: Compute lowercase GHCR owner
@@ -84,13 +87,38 @@ jobs:
with: with:
context: . context: .
file: apps/${{ matrix.app }}/Dockerfile file: apps/${{ matrix.app }}/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/${{ matrix.platform }}
push: true push: true
tags: | tags: ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }}-${{ matrix.platform }}
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }} cache-from: type=gha,scope=${{ matrix.app }}-${{ matrix.platform }}
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:latest cache-to: type=gha,mode=max,scope=${{ matrix.app }}-${{ matrix.platform }}
cache-from: type=gha,scope=${{ matrix.app }}
cache-to: type=gha,mode=max,scope=${{ matrix.app }} # Joins the per-arch images into the real version + latest tags.
manifests:
name: Image manifests
needs: [verify, images]
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
steps:
- uses: docker/setup-buildx-action@v3
- name: Compute lowercase GHCR owner
run: echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create multi-arch manifests
env:
VERSION: ${{ needs.verify.outputs.version }}
run: |
set -e
for app in backend auth frontend; do
img="ghcr.io/${GHCR_OWNER}/primora-${app}"
docker buildx imagetools create \
-t "$img:$VERSION" -t "$img:latest" \
"$img:$VERSION-amd64" "$img:$VERSION-arm64"
done
cli-binaries: cli-binaries:
name: Compile ${{ matrix.name }} name: Compile ${{ matrix.name }}
@@ -146,6 +174,11 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Build Tauri bundles - name: Build Tauri bundles
# Updater artifacts are minisign-signed; without the key the build
# still succeeds but emits no .sig files (updater can't verify them).
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run bundle --workspace @primora/desktop run: npm run bundle --workspace @primora/desktop
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
@@ -155,8 +188,11 @@ jobs:
apps/desktop/src-tauri/target/release/bundle/**/*.deb apps/desktop/src-tauri/target/release/bundle/**/*.deb
apps/desktop/src-tauri/target/release/bundle/**/*.rpm apps/desktop/src-tauri/target/release/bundle/**/*.rpm
apps/desktop/src-tauri/target/release/bundle/**/*.dmg apps/desktop/src-tauri/target/release/bundle/**/*.dmg
apps/desktop/src-tauri/target/release/bundle/**/*.app.tar.gz
apps/desktop/src-tauri/target/release/bundle/**/*.msi apps/desktop/src-tauri/target/release/bundle/**/*.msi
apps/desktop/src-tauri/target/release/bundle/**/*.exe apps/desktop/src-tauri/target/release/bundle/**/*.exe
apps/desktop/src-tauri/target/release/bundle/**/*.nsis.zip
apps/desktop/src-tauri/target/release/bundle/**/*.sig
if-no-files-found: error if-no-files-found: error
npm-packages: npm-packages:
@@ -184,7 +220,7 @@ jobs:
release: release:
name: GitHub Release name: GitHub Release
needs: [verify, images, cli-binaries, desktop, npm-packages] needs: [verify, manifests, cli-binaries, desktop, npm-packages]
if: needs.verify.outputs.needed == 'true' if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
@@ -225,6 +261,48 @@ jobs:
flag flag
' CHANGELOG.md > notes.md ' CHANGELOG.md > notes.md
[ -s notes.md ] || echo "Release ${VERSION}" > notes.md [ -s notes.md ] || echo "Release ${VERSION}" > notes.md
# Tauri updater manifest: one platform entry per signed updater
# artifact (AppImage.sig / app.tar.gz.sig / nsis.zip.sig). Missing
# sigs mean TAURI_SIGNING_PRIVATE_KEY isn't set — then there is no
# manifest and no update, which is correct (never ship unsigned).
- name: Build updater manifest (latest.json)
run: |
python3 - <<'PY'
import json, os, glob
base = "https://github.com/Dvorinka/Primora/releases/download/%s" % os.environ["VERSION"]
platforms = {}
for sig in glob.glob("dist-release/**/*.sig", recursive=True):
asset = sig[:-4]
name = os.path.basename(asset)
if name.endswith(".AppImage"):
plat = "linux-x86_64"
elif name.endswith(".app.tar.gz"):
plat = "darwin-aarch64" if "aarch64" in name else "darwin-x86_64"
if "aarch64" not in name and "x86_64" not in name:
plat = "darwin-aarch64" # macos-latest is arm64
elif name.endswith(".nsis.zip"):
plat = "windows-x86_64"
else:
continue
platforms[plat] = {
"signature": open(sig).read().strip(),
"url": f"{base}/{name}",
}
manifest = {
"version": os.environ["VERSION"],
"notes": "See the release notes.",
"pub_date": __import__("datetime").datetime.now(
__import__("datetime").timezone.utc
).strftime("%Y-%m-%dT%H:%M:%SZ"),
"platforms": platforms,
}
if platforms:
with open("dist-release/latest.json", "w") as f:
json.dump(manifest, f, indent=2)
print("latest.json:", ", ".join(platforms))
else:
print("no signed updater artifacts — skipping latest.json")
PY
- name: Create release - name: Create release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -234,6 +312,61 @@ jobs:
mapfile -t files < <(find dist-release -type f) mapfile -t files < <(find dist-release -type f)
[ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; } [ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; }
gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}" gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}"
# The manifest must be fetchable at /releases/latest/download/latest.json
if [ -f dist-release/latest.json ]; then
gh release upload "$VERSION" dist-release/latest.json --clobber
fi
# Software bill of materials — CycloneDX JSON per published image plus
# one for the source tree (npm + Go modules). Attached to the release
# next to the binaries; downstream consumers can verify provenance.
sbom:
name: SBOMs
needs: [verify, release]
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
permissions:
packages: read
# contents: write — the job uploads SBOM assets onto the release.
contents: write
env:
VERSION: ${{ needs.verify.outputs.version }}
steps:
- uses: actions/checkout@v4
# ghcr.io requires lowercase repo paths; the owner login may be mixed-case.
- name: Compute lowercase GHCR owner
run: echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# anchore/sbom-action does not create the output directory.
- run: mkdir -p dist-release
- uses: anchore/sbom-action@v0
with:
image: ghcr.io/${{ env.GHCR_OWNER }}/primora-backend:${{ needs.verify.outputs.version }}
format: cyclonedx-json
output-file: dist-release/sbom-backend.cdx.json
- uses: anchore/sbom-action@v0
with:
image: ghcr.io/${{ env.GHCR_OWNER }}/primora-auth:${{ needs.verify.outputs.version }}
format: cyclonedx-json
output-file: dist-release/sbom-auth.cdx.json
- uses: anchore/sbom-action@v0
with:
image: ghcr.io/${{ env.GHCR_OWNER }}/primora-frontend:${{ needs.verify.outputs.version }}
format: cyclonedx-json
output-file: dist-release/sbom-frontend.cdx.json
- uses: anchore/sbom-action@v0
with:
path: .
format: cyclonedx-json
output-file: dist-release/sbom-source.cdx.json
- name: Attach SBOMs to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$VERSION" dist-release/sbom-*.cdx.json --clobber
npm-publish: npm-publish:
name: Publish to npm name: Publish to npm
+2
View File
@@ -39,3 +39,5 @@ infra/mailpit
# Generated code-graph artifacts # Generated code-graph artifacts
graphify-out/ graphify-out/
.playwright-mcp/ .playwright-mcp/
# Local brag/launch-video output
brag-output/
+90
View File
@@ -2,6 +2,96 @@
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/). All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
## [Unreleased]
## [0.8.4] - 2026-09-28
### Added
- **Slugs auto-derive from names** — typing an organization, project, bucket, or collection name now fills its slug (`TDvorak` → `tdvorak`); hand-editing the slug stops the derivation, and manual edits are sanitized as you type. Edit forms stay manual.
### Fixed
- **Installer URL prompt was invisible under `curl | bash`** — `exec 9</dev/tty 2>/dev/null` sent the whole shell's stderr to `/dev/null` permanently, so the `read` prompt rendered nowhere while the script blocked on input. The prompt now reads `/dev/tty` directly with a quiet probe; no-tty runs default silently.
- **Installer fetches fail fast** — the compose-file and source-tarball `curl` calls now carry connect/max-time timeouts plus retries, so a stalled GitHub connection errors within seconds naming the file instead of hanging forever.
## [0.8.3] - 2026-09-28
### Added
- **Install script asks for the public URL** — `install.sh` now prompts for where you'll open the app and accepts a full URL: `https://primora.example.com` (TLS at your proxy, hints printed at the end), `primora.example.com`, or `192.168.1.50:8085` (the port becomes `NGINX_PORT`). The answer rewrites every public URL var in `.env` and `COOKIE_DOMAIN` for real DNS names. The prompt reads the controlling terminal so it appears under `curl | bash`; no tty (CI, cron) falls back to `localhost`. Non-interactive via `PUBLIC_URL=`; legacy `DOMAIN`/`NGINX_PORT` envs still work; reruns over an existing `.env` skip the question.
### Fixed
- **Auth origins behind external TLS terminators** — trusted origins now accept both URL schemes on the serving host. Verified live over a Cloudflare quick tunnel: a real browser session cookie + `https://*.trycloudflare.com` origin was 403'd on v0.8.2 (nginx reports `X-Forwarded-Proto: http` inbound); the same request now signs in cleanly while cross-site origins still 403.
## [0.8.2] - 2026-09-28
### Fixed
- **Self-hosted sign-up works on any origin** — trusted origins are now derived from the request's public host (forwarded by nginx), so browsing the instance via a LAN IP, hostname, or custom port no longer fails every auth POST with a 403. Static `VITE_APP_URL`/`AUTH_BASE_URL` origins and `BETTER_AUTH_TRUSTED_ORIGINS` still apply.
- **Sign-up/sign-in failures are surfaced, not swallowed** — better-auth resolves `{ data, error }` instead of throwing, so a failed request previously showed "Account created — signing you in" and bounced back to the bootstrap screen. The real server error (invalid origin, rate limit, disabled sign-up) is now displayed.
- **Mail probe can no longer break auth** — `hasMailTransport` degrades to "no transport" instead of throwing when `core.settings` is unreadable, and the no-transport startup backfill is best-effort. Previously, auth could crash-loop while the backend was still migrating on a fresh volume — every sign-up 502'd during the window.
## [0.8.1] - 2026-09-28
### Fixed
- **SMTP-less instances no longer involve email at all** — when no mail transport is configured (no Resend key, no `SMTP_HOST`), new accounts are created `emailVerified=true`, the verification send is skipped entirely, and existing unverified users are marked verified on startup. `.env.example` no longer defaults `SMTP_HOST` to the dev-only `mailpit` service, which left production deploys pointing at a transport that does not exist.
## [0.8.0] - 2026-09-28
### Added
- **Agent setup prompt** — a "Set up with an AI agent" dialog that mints a fresh `write`-scoped API key (ingest + reads + non-admin mutations, never key-minting) and renders a copyable prompt handing a coding agent everything it needs: the deployment endpoint, project id, the key itself, plus tasks to instrument error/metric/log/heartbeat reporting per component (`POST /api/v1/ingest` — works from any language), register the app's databases via `db-connections` (postgres → cassandra), wire the MCP server / CLI for future agents, and verify with a test event + deploy marker. Opens at the end of the new-project onboarding and is re-reachable from the project dashboard and Projects page; keys are shown only inside the prompt (revoke under Settings → API keys).
- **Local secrets vault in the CLI** (`@primora/cli` 0.6.0) — `primora vault init/status` and `primora secrets set|get|list|rm|import` manage an encrypted `~/.config/primora/vault.bin` (Argon2id 64 MiB/3/4 → XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes). Entries carry value + optional `url`/`notes` metadata. Password via `PRIMORA_VAULT_PASSWORD`, `--password-file`, or hidden prompt; file path overridable with `PRIMORA_VAULT`. Clean-room format — no external vault compatibility.
- **Vault sessions** — `primora vault unlock --ttl 900` proves the password once and writes a TTL'd `vault.session` (0600); agents and scripts use the vault without the password. `primora vault lock` revokes immediately.
- **`primora agent` credential broker** — wraps a child process with dummy tokens and a loopback base URL; the proxy attaches the real secret on the wire per grant (TTL, cap 3600 s). Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`, `github`, `npm`; `--upstream name=https://…` covers arbitrary Bearer APIs. Base-URL broker, not TLS MITM — tools must honor a base-URL override.
- **`primora inject`** — runs a child process with vault secrets in its environment (`--all`, or `--env-file` templates containing `primora://NAME` refs). Nothing on disk, nothing in argv.
- **`primora stack:*` operator group** — `up`, `down`, `status`, `logs [service]`, `pull`, `backup` wrap `docker compose` in `--dir` (default cwd). `stack up --vault` materializes `.env` from the vault for the duration of the run (refuses to clobber an existing `.env` without `--force`, which swaps and restores it). `stack backup` writes a `pg_dump -Fc` of the platform database to `./backups`.
- **Vault MCP tools** (`@primora/mcp`) — `primora_vault_status`, `primora_vault_list` (metadata only — values are never returned), and `primora_vault_exec` which runs `primora` commands with a secret injected into the child environment; `vault`/`secrets`/`inject`/`agent`/`login`/`logout`/`use` and `keys:create` are denied so no response can carry a credential. (Hardened to a strict allow-list — see Security.)
- **Secrets Vault window in the desktop app** (`@primora/desktop`) — tray "Secrets Vault…" opens a dedicated window (`ui/vault.html`) covering init, timed unlock/lock with live countdown, secret list with copy-to-clipboard (values never displayed), add/delete, and `.env` import. IPC shells out to the `primora` binary (`PRIMORA_CLI` overrides), so desktop and terminal share one vault file and one crypto implementation; a missing CLI shows install guidance. Also reachable from the connect screen.
- **Project vault — server-side secrets** (`core.project_secrets`, migration 00011) — per-project secrets encrypted AES-256-GCM at rest via the existing encryptor. API: `GET/PUT/DELETE /projects/:projectID/secrets[/:name]` plus `POST …/reveal`. Lists return metadata only (name, url, notes, timestamps); reveal decrypts explicitly and is audit-logged (`secret.set`/`secret.revealed`/`secret.deleted`). Scheduled-job payloads reference secrets as `secret://NAME`, resolved to plaintext at delivery — refs are stored, values are not; unresolved or unconfigured refs fail the run loudly.
- **Vault page in the dashboard** — project secrets table with create/update, `.env` import, delete, and copy-to-clipboard reveal; plus a "This machine" card when running inside the desktop shell that drives the local vault through the same Tauri IPC commands as the vault window.
- **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values.
- **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext.
- **Realtime channel subscriptions** (`@primora/client`) — `createRealtimeClient({ endpoint, key, projectId })` opens the project SSE stream (`/realtime/stream`) via fetch + ReadableStream (EventSource can't send `X-API-Key`). `channel("documents").on("created", cb)` subscribes by event namespace (`document`/`object`/`issue`/`deploy`, `"*"` for all); auto-reconnects with capped exponential backoff; `onStateChange` reports `connecting|open|reconnecting|closed`.
- **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components.
- **Inbound webhooks** (`core.inbound_hooks`, migration 00013) — public ingest endpoints `POST /api/v1/hooks/<token>` (the token is the credential). `event` mode republishes the body as `inbound.received` domain events through the signed dispatcher + realtime; `job` mode triggers a scheduled-job run with the received body as its payload (`triggered_by: "hook"`). Optional `secret` requires `X-Primora-Signature` HMAC-SHA256 — the same scheme outbound deliveries use. Bodies capped at 256 KiB. Managed from Integrations → Webhooks.
- **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection <slug> --filter … --order …`.
- **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test.
- **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations.
- **S3-compatible object storage backend** — `BACKEND_STORAGE_DRIVER=s3` swaps the local disk store for any S3-compatible API (AWS, MinIO, Garage, R2) via a stdlib-only SigV4 implementation — no AWS SDK dependency. `S3_ENDPOINT`/`S3_REGION`/`S3_BUCKET`/credentials configure it; `S3_PATH_STYLE` (default on) covers MinIO/Garage, off for AWS virtual-hosted. The configured bucket is created on startup if missing; objects stream with computed SHA-256, `storage_path` records `s3://bucket/key`. Verified live end-to-end (bucket → put → get → copy → list → delete).
- **Functions** (`core.functions` + `core.function_runs`, migration 00015) — project-scoped JS/TS executed by a runtime binary on the backend host (`bun` or `deno`, per function). The JSON payload arrives on stdin and as `PRIMORA_PAYLOAD`; stdout/stderr are captured per run (capped at 64 KiB) with status, exit code and duration — `FUNCTIONS_RUNTIME`/`FUNCTIONS_TIMEOUT_SECONDS`/`FUNCTIONS_MAX_OUTPUT_BYTES` configure the runner. API: CRUD under `/projects/:id/functions`, `POST …/invoke` (502 + recorded run on failure), `GET …/runs`. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Code executes with backend privileges — the documented self-hosted trust model.
- **Presigned object URLs** — `POST /buckets/:bucketID/object-presigns` mints SigV4 query-signed `GET`/`PUT` URLs (60 s–1 h TTL) so clients transfer large objects directly against S3 without proxying through the backend. Read role for downloads (object must exist — no key probing), write role for uploads. `S3_PUBLIC_ENDPOINT` rewrites the client-facing host for internal `S3_ENDPOINT`s; signatures bind the host so URLs are valid only where they were minted. The local driver returns a clear error — a presigned URL needs a real object API.
- **Desktop auto-updater** — `tauri-plugin-updater` wired end to end: minisign keypair, `createUpdaterArtifacts`, `latest.json` manifest built from signed artifacts on every release, and a silent background check on the connect screen offering "Update & restart" (download → verify → install → relaunch). Unsigned artifacts can never install — missing signature means no manifest, not a bypass. CI signs via `TAURI_SIGNING_PRIVATE_KEY` secrets; OS-level signing (notarization/Authenticode) remains open.
- **Release SBOMs** — CycloneDX SBOM for each published GHCR image (backend/auth/frontend) plus one for the source tree, attached to every GitHub Release via `anchore/sbom-action`.
- **Sandboxed function driver** — `FUNCTIONS_DRIVER=docker` runs every invoke in an ephemeral locked-down container: no network, read-only rootfs, 128 MiB memory, 0.5 CPU, 64 pids, non-root, all capabilities dropped. Source is injected via base64 env (no bind mount — works on Docker Desktop and remote daemons); timeout force-removes the container so nothing lingers. Verified live: code runs, network is unreachable inside, and deadline kills land.
- **Client events + presence** — `POST /projects/:id/events` publishes client-authored events (`custom.*` namespace enforced — system types can't be spoofed) through the same fan-out as system events: realtime subscribers, matching webhooks, and `event_pattern` functions all fire. Realtime now tracks presence: `presence.update` {online} broadcasts on every subscribe/unsubscribe (stream-only — never reaches webhooks or functions), `GET …/realtime/presence` reads the count. Client SDK gains `publish()` and `presence()`; CLI gains `events:send`.
- **Function triggers** (migration 00016) — three new ways to run a function, all through the same executor and run log. Scheduled jobs accept `function_id` instead of `url` (run status mirrors the exit code); inbound hooks gain `mode: "function"` — the received body becomes the function's stdin payload, acked immediately with the run recorded async; and `event_pattern` on a function (`document.*`, `*`, or an exact type) fires it detached whenever a matching domain event publishes — `{event, project_id, occurred_at, data}` as payload. Runs record trigger source: `manual`/`schedule`/`hook`/`event`. Dashboard: function event-trigger field, job target picker (URL or function), and inbound-hook function mode.
- **`GET /api/v1/context`** — actor self-discovery: for API keys returns scopes, key prefix, and the resolved organization + project (id/slug/name). Fixes the API-key onboarding dead end: `primora login --api-key` now validates the key against the instance before saving and seeds org/project context automatically; `primora use`, `context`, and `whoami` work under API-key auth via this endpoint.
- **CLI `orgs:rm` / `projects:rm` / `buckets:rm`** — destructive deletes gated behind `--yes` in non-interactive mode, interactive confirm otherwise. `buckets:rm` resolves id/slug/name.
- **Webhook wildcard subscriptions** — `events` accepts `*`, `<namespace>.*`, and `custom.*` patterns, matched at dispatch time via SQL `LIKE` — the same semantics function `event_pattern` already used. Client-published `custom.*` events can now be webhooked.
### Security
- **`primora_vault_exec` rewritten as an allow-list** — the deny-list was bypassed by colon-form commands (`["secrets:get","X"]` as a single token evaded the pair check), exposing every vault secret; `--remote` variants revealed project secrets; and the `secret` param was used verbatim as an env name, allowing `NODE_OPTIONS=--require <file>` host code execution. Now only a fixed read-mostly command set runs (`whoami`, `context`, `orgs:list`, `projects:list`, `buckets:list`, `objects:list`, `documents:list`, `jobs:list`, `jobs:runs`, `audit:list`, `keys:list`, `events:send`), path/file flags and `..` args are rejected, injected env names must match `^[A-Z_][A-Z0-9_]{0,63}$`, and loader-hook names (`NODE_*`, `LD_*`, `DYLD_*`, `PATH`, `BASH*`, `PYTHON*`, `GIT_*`, `SSL_CERT*`, `XDG_*`, …) are blocked. Verified: every prior bypass denied, allowed commands still inject.
- **SSRF hardening on webhook/job URLs** — cloud metadata endpoints (`169.254.169.254`, `fd00:ec2::254`) are excluded from the link-local/private allowance, and pure decimal/hex "bare hostnames" (`2130706433`, `0x7f000001` — non-canonical IP encodings of loopback) are no longer treated as Docker service names.
- **Path params now `encodeURIComponent`** in all generated-client consumers (CLI, MCP, dashboard) — `encodeURI` left `?#&` intact, smuggling query segments through `{name}` params (e.g. `secrets/a?reveal=1`).
- **Strict base64 in `primora_upload_object`** — `Buffer.from(b64)` silently drops invalid characters; `contentBase64` is now validated (whitespace-tolerant) so corrupt input can't silently store garbage bytes.
- **MCP no-creds startup** — a missing config/API key previously threw inside `configureClient`, killing the process mid-handshake (protocol hang, stack on stderr). Startup now degrades: `initialize`/`tools/list` answer normally and every tool call returns the actionable error.
### Fixed
- **Silent CLI dispatch** — unknown commands, a bare `primora`, and group names without a verb exited 0 with no output. Now: unknown input errors with exit 1 (bare groups list their verbs), bare `primora` prints help.
- **Arg-parser numeric coercion** — `cac`/`mri` coerced numeric-looking flag values (`--value "007"` → `7`, `--value ""` → `0`), corrupting secrets and keys. Parsed options are now re-read as raw strings from argv for every non-boolean flag; `--value 007` round-trips intact.
- **`pgx.ErrNoRows` → 404** in the shared error mapper — missing resources returned raw "no rows in result set" 500s across all handlers.
- **Structured error hygiene** — `pgconn.PgError` codes map to clean statuses (23505 → 409 "resource already exists", 23503 → 409, 22001/54000 → 400 "value too large", 22021 → 400) instead of leaking SQLSTATE/constraint names; validator failures report `field is required`/`field failed <tag> validation`; a new `services.InputError` type maps service-layer input failures to 400 (webhook URL/event-type errors were 500s). Presign on the local driver returns `presign_unsupported` 400.
- **`fetch failed` polish** — connection failures report the cause code and suggest `primora context` instead of bare undici errors.
- **`login --json`** — the flag now emits JSON on both the session and API-key paths.
- **Dashboard fonts vendored** — Geist/Geist Mono now ship via `@fontsource` inside the bundle instead of a Google Fonts `<link>` the nginx CSP blocked (`style-src 'self'`). Removes the runtime Google dependency, works airgapped, and eliminates the console CSP violation.
- **Release image builds moved off QEMU** — arm64 `npm ci` under `docker/setup-qemu-action` SIGILLed on current `node:20-alpine` layers (`uncaught target signal 4`) and wedged the build step for over an hour. Images now build per-arch on native runners (`ubuntu-24.04-arm` for arm64) and a `manifests` job joins them into the multi-arch `:<version>`/`:latest` tags.
## [0.7.1] - 2026-09-23 ## [0.7.1] - 2026-09-23
### Fixed ### Fixed
+30 -6
View File
@@ -37,11 +37,18 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
| `DRAGONFLY_URL` | backend → cache DSN | yes | | `DRAGONFLY_URL` | backend → cache DSN | yes |
| `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes | | `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes |
| `BETTER_AUTH_SECRET` | auth session/signing secret | yes | | `BETTER_AUTH_SECRET` | auth session/signing secret | yes |
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for integration credentials + webhook secrets at rest | production | | `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_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | 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 | | `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container | yes | | `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 | | `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 | | `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 | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
@@ -127,10 +134,27 @@ docker compose up -d # 3. rolling recreate; backend migrate
## Storage constraint ## Storage constraint
Object storage is the **local filesystem** (`backend_storage` volume). There Object storage defaults to the **local filesystem** (`backend_storage`
is no S3-compatible backend yet — the volume must be on durable, backed-up volume) — durable, backed-up disk on a single host. For multi-node or
disk, and a single host serves all objects. Plan capacity accordingly; external object stores set `BACKEND_STORAGE_DRIVER=s3` + the `S3_*`
multi-node deployments need an external object store first. 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 ## Observability
+5
View File
@@ -61,9 +61,14 @@ The important ones:
| `POSTGRES_*` / `DATABASE_URL` | Postgres connection | | `POSTGRES_*` / `DATABASE_URL` | Postgres connection |
| `DRAGONFLY_URL` | Cache/rate-limit store | | `DRAGONFLY_URL` | Cache/rate-limit store |
| `SMTP_*` / `MAIL_FROM` | Transactional email (Mailpit in dev, or Resend via `RESEND_API_KEY`) | | `SMTP_*` / `MAIL_FROM` | Transactional email (Mailpit in dev, or Resend via `RESEND_API_KEY`) |
| `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 | | `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 | | `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 | | `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app |
| `BACKEND_STORAGE_DRIVER` | `local` (default, `BACKEND_STORAGE_ROOT`) or `s3` — S3-compatible object storage |
| `S3_ENDPOINT` / `S3_BUCKET` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 backend config (with `S3_REGION`, `S3_PREFIX`, `S3_PATH_STYLE`) |
| `S3_PUBLIC_ENDPOINT` | Client-facing S3 host for presigned URLs when `S3_ENDPOINT` is internal-only |
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` | JS runtime for Functions — `auto` (default, resolves `bun`/`deno` from PATH) or a binary path; 30 s default timeout |
| `VITE_DEMO_MODE` | Enable the client-side demo workspace | | `VITE_DEMO_MODE` | Enable the client-side demo workspace |
### Instance administration ### Instance administration
+12 -5
View File
@@ -48,9 +48,14 @@ Supabase, with no hosted tier - your data stays on your hardware.
- **Organizations & projects** - workspaces with scoped org and project roles. - **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. - **Auth** - email/password plus optional GitHub, Google, Discord, and Microsoft OAuth. JWTs verified by the API against JWKS.
- **Storage** - S3-style buckets on your local filesystem, public or private. - **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. - **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. - **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. - **Audit log** - every mutating request recorded, with CSV/JSON export.
- **Demo mode** - fully client-side seeded workspace via `?demo=true`. - **Demo mode** - fully client-side seeded workspace via `?demo=true`.
@@ -72,7 +77,9 @@ Prerequisites: Docker with the Compose plugin.
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | 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. Installs into `./primora`, asks one question — the URL you'll open the app on
(domain, IP, optional port) — generates secrets, and starts the stack.
Non-interactive: `PUBLIC_URL=https://primora.example.com bash install.sh`.
Or from a clone: Or from a clone:
```bash ```bash
@@ -91,9 +98,9 @@ and [QUICK_START.md](QUICK_START.md#configuration) for the important ones.
## Ecosystem ## Ecosystem
- **[CLI](apps/cli)** - `primora` for orgs, projects, buckets, objects, keys, and audit from the terminal. - **[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. - **[MCP server](apps/mcp)** - exposes `primora_*` tools to MCP clients.
- **[Desktop](apps/desktop)** - Tauri 2 shell connecting to your deployment, with tray presence. - **[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. - **PWA** - the dashboard is installable on mobile and desktop browsers.
## Documentation ## Documentation
+158 -28
View File
@@ -123,8 +123,11 @@ Follow-ups:
- Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run - Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run
(host-verified on Linux only). (host-verified on Linux only).
- Desktop bundle signing + auto-updater — unsigned artifacts today; add when - ~~Desktop bundle signing + auto-updater~~ ✅ — `tauri-plugin-updater`
distribution matures. 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) ✅ ## Phase 6 — Production hardening (v0.6.0) ✅
@@ -185,8 +188,9 @@ release pipeline with images + binaries + desktop bundles.
(auto-registers from ingest). (auto-registers from ingest).
- ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres → - ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres →
dragonfly transfer, key templating, TTL). dragonfly transfer, key templating, TTL).
- ~~**External object storage**~~ ✅ — constraint documented in - ~~**External object storage**~~ ✅ — `BACKEND_STORAGE_DRIVER=s3` shipped
DEPLOYMENT_GUIDE.md / PRODUCTION_READINESS.md; S3 backend deferred. (Phase 10): any S3-compatible API + presigned URLs; local disk remains
the default.
### Testing ### Testing
@@ -205,8 +209,10 @@ release pipeline with images + binaries + desktop bundles.
- ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via - ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via
QEMU + buildx. QEMU + buildx.
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle - Carried forward from Phase 5 follow-ups: macOS/Windows bundle
verification, signing + auto-updater. verification; updater + minisign signing shipped (OS-level signing
- Optional: SBOM + provenance attestation on release images. remains open).
- ~~Optional: SBOM~~ ✅ — CycloneDX SBOMs per published image + source
tree, attached to every release (`anchore/sbom-action`).
### Docs ### Docs
@@ -263,32 +269,156 @@ dashboard's live feed off the same stream.
`auth-client` now resolves `/auth` against `window.location.origin`, `auth-client` now resolves `/auth` against `window.location.origin`,
preserving same-origin defaults without breaking split-origin dev. 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 ## Future phases — candidates
Ordered loosely by leverage. None committed; each gets scoped when picked. Ordered loosely by leverage. None committed; each gets scoped when picked.
- **Phase 8 — Client realtime SDK** — `@primora/client` channel - **Phase 9 — Functions** — ✅ shipped: `core.functions` +
subscriptions (`channel('documents').on('created', …)`), presence, `core.function_runs`, bun/deno exec runner (stdin payload, env injection,
and broadcast over the Phase 7 stream. Supabase-realtime parity is a capped output, timeout), invoke API + dashboard page. Triggers shipped:
protocol question, not a transport one. scheduled jobs (`function_id` on a job), inbound hooks (`mode:
- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers. "function"`), and domain events (`event_pattern`, e.g. `document.*`).
Deno or Bun isolates in a sidecar; secrets via env, payload via stdin. Runs record trigger source (`manual`/`schedule`/`hook`/`event`).
The job runner already supplies the scheduling half. Isolation shipped: `FUNCTIONS_DRIVER=docker` runs every invoke in an
- **Phase 10 — Storage backends** — S3-compatible object storage behind ephemeral locked-down container (no network, read-only fs, non-root).
the existing bucket abstraction (deferred from Phase 6), plus Remaining candidate: Firecracker/gVisor microVMs for hostile
presigned-URL uploads for large files. multi-tenant workloads — docker socket trust is documented.
- **Phase 11 — Generated REST per collection** — PostgREST-style - **Phase 10 — Storage backends** — ✅ shipped: `BACKEND_STORAGE_DRIVER=s3`
auto-CRUD + filtering over Collections documents; a clean read layer + `S3_*` env vars, stdlib SigV4, verified against a real S3 server.
on data that already exists. Presigned upload/download URLs shipped: `POST
- **Notifications** — alert rules on telemetry (error-rate thresholds, /buckets/:id/object-presigns` mints SigV4 query-signed GET/PUT URLs
heartbeat silence) fanning out through the webhook dispatcher. (`S3_PUBLIC_ENDPOINT` rewrites the client-facing host); local driver
- **Inbound webhooks** — project-scoped ingest endpoints that turn returns a clear error — a presigned URL needs a real object API behind it.
external events into domain events/jobs. - **HA mode** — ✅ shipped: PostgreSQL advisory-lock leadership for the
- **HA mode** — leader-elected scheduler + distributed locks so two scheduler and alert evaluator; failover verified with two replicas.
backend replicas don't double-fire jobs; Postgres advisory locks are - **Email surface** — ✅ shipped: templated sends + `core.email_log` +
enough at this scale. dashboard card; more templates land as new flows need them.
- **Email surface** — transactional templates + send log in the
dashboard, backed by the auth service's existing SMTP/Resend wiring. ## 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`.
--- ---
+2 -1
View File
@@ -5,7 +5,7 @@ import { requestId } from "hono/request-id";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
import { createClient } from "redis"; import { createClient } from "redis";
import { auth, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js"; import { auth, promoteAdminEmails, runAuthMigrations, verifyUsersWithoutMailTransport } from "./lib/auth.js";
import { authPool } from "./lib/db.js"; import { authPool } from "./lib/db.js";
import { env } from "./lib/env.js"; import { env } from "./lib/env.js";
@@ -20,6 +20,7 @@ try {
} }
await retry("auth_migrations", runAuthMigrations); await retry("auth_migrations", runAuthMigrations);
await verifyUsersWithoutMailTransport();
await promoteAdminEmails(); await promoteAdminEmails();
const app = new Hono(); const app = new Hono();
+42 -4
View File
@@ -4,7 +4,7 @@ import { admin, jwt } from "better-auth/plugins";
import { getMigrations } from "better-auth/db/migration"; import { getMigrations } from "better-auth/db/migration";
import { authPool } from "./db.js"; import { authPool } from "./db.js";
import { sendTransactionalEmail } from "./mail.js"; import { hasMailTransport, sendTransactionalEmail } from "./mail.js";
import { getBoolSetting } from "./settings.js"; import { getBoolSetting } from "./settings.js";
import { env } from "./env.js"; import { env } from "./env.js";
@@ -53,7 +53,22 @@ export const auth = betterAuth({
appName: "Primora", appName: "Primora",
secret: env.BETTER_AUTH_SECRET, secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL, baseURL: env.BETTER_AUTH_URL,
trustedOrigins: [env.VITE_APP_URL, env.AUTH_BASE_URL], // Self-host: the browser reaches /auth through the same nginx origin as the
// app, so whatever host actually served this request is trusted — static
// localhost defaults alone 403 every sign-up on LAN IPs or custom ports.
// An attacker's cross-site Origin still won't match the server host.
trustedOrigins: (request) => {
const origins = [env.VITE_APP_URL, env.AUTH_BASE_URL];
const host = request?.headers.get("x-forwarded-host") ?? request?.headers.get("host");
if (host) {
const proto = request?.headers.get("x-forwarded-proto") ?? "http";
origins.push(`${proto}://${host}`);
// TLS often terminates at an external proxy — nginx then reports http.
// Both schemes on the serving host are legitimately this instance.
origins.push(proto === "https" ? `http://${host}` : `https://${host}`);
}
return origins;
},
database: authPool, database: authPool,
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
@@ -72,13 +87,20 @@ export const auth = betterAuth({
// Gates only the public sign-up endpoint. Users created through the // Gates only the public sign-up endpoint. Users created through the
// admin plugin or OAuth callbacks take different paths and pass. // admin plugin or OAuth callbacks take different paths and pass.
async before(user, ctx) { async before(user, ctx) {
if (ctx?.path !== "/sign-up/email") return; const data = { ...user };
// Without a mail transport the verification mail can never arrive —
// mark every created account verified so nothing downstream nags
// "unverified" on users nobody can verify.
if (!(await hasMailTransport())) {
data.emailVerified = true;
}
if (ctx?.path === "/sign-up/email") {
const { rows } = await authPool.query<{ n: number }>( const { rows } = await authPool.query<{ n: number }>(
`select count(*)::int as n from "user"`, `select count(*)::int as n from "user"`,
); );
if (rows[0].n === 0) { if (rows[0].n === 0) {
// Bootstrap: the first public signup becomes the instance admin. // Bootstrap: the first public signup becomes the instance admin.
return { data: { ...user, role: "admin" } }; return { data: { ...data, role: "admin" } };
} }
const enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED); const enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED);
if (!enabled) { if (!enabled) {
@@ -86,6 +108,8 @@ export const auth = betterAuth({
message: "Sign-up is disabled on this instance.", message: "Sign-up is disabled on this instance.",
}); });
} }
}
return data.emailVerified === user.emailVerified ? undefined : { data };
}, },
}, },
}, },
@@ -94,6 +118,9 @@ export const auth = betterAuth({
sendOnSignUp: true, sendOnSignUp: true,
autoSignInAfterVerification: true, autoSignInAfterVerification: true,
async sendVerificationEmail({ user, url }) { async sendVerificationEmail({ user, url }) {
// No transport at all → the mail can never arrive; the user was already
// marked verified at creation.
if (!(await hasMailTransport())) return;
// Verification is optional — a misconfigured transport must not fail // Verification is optional — a misconfigured transport must not fail
// sign-up or sign-in. // sign-up or sign-in.
try { try {
@@ -143,6 +170,17 @@ export async function runAuthMigrations() {
await migrations.runMigrations(); await migrations.runMigrations();
} }
/** No transport → existing unverified users can never resolve that state.
* Best-effort: a startup failure here must never take the service down. */
export async function verifyUsersWithoutMailTransport() {
try {
if (await hasMailTransport()) return;
await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`);
} catch (error) {
console.warn(JSON.stringify({ level: "warn", msg: "verify_without_transport_failed", error }));
}
}
/** Bootstrap admins by email — run after migrations so the role column exists. */ /** Bootstrap admins by email — run after migrations so the role column exists. */
export async function promoteAdminEmails() { export async function promoteAdminEmails() {
const emails = (env.AUTH_ADMIN_EMAILS ?? "") const emails = (env.AUTH_ADMIN_EMAILS ?? "")
+15
View File
@@ -30,6 +30,21 @@ async function resolveMailConfig() {
} as const; } as const;
} }
// True when a mail transport exists (Resend key or SMTP host). Verification
// and reset mails only make sense when mail can actually leave the box.
// Mail is optional — a failed settings read must degrade to "no transport",
// never break sign-up.
export async function hasMailTransport(): Promise<boolean> {
try {
const cfg = await resolveMailConfig();
if ("resendApiKey" in cfg && cfg.resendApiKey) return true;
return "smtp" in cfg && Boolean(cfg.smtp);
} catch (error) {
console.warn(JSON.stringify({ level: "warn", msg: "mail_transport_probe_failed", error }));
return false;
}
}
export async function sendTransactionalEmail(input: { export async function sendTransactionalEmail(input: {
to: string; to: string;
subject: string; subject: string;
+3
View File
@@ -34,6 +34,9 @@ RUN apt-get update \
&& chmod +x /usr/local/bin/dbx-mcp \ && chmod +x /usr/local/bin/dbx-mcp \
&& rm /tmp/dbx-mcp.tar.gz && rm /tmp/dbx-mcp.tar.gz
# Functions runtime — bun static binary from the official image (multi-arch).
COPY --from=oven/bun:1.3.14 /usr/local/bin/bun /usr/local/bin/bun
COPY --from=builder /primora-backend /usr/local/bin/primora-backend COPY --from=builder /primora-backend /usr/local/bin/primora-backend
COPY --from=builder /app/db ./db COPY --from=builder /app/db ./db
COPY --from=builder /app/openapi ./openapi COPY --from=builder /app/openapi ./openapi
@@ -0,0 +1,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;
+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;
+4 -2
View File
@@ -17,8 +17,9 @@ INSERT INTO core.scheduled_jobs (
payload, payload,
enabled, enabled,
next_run_at, next_run_at,
created_by_user_id created_by_user_id,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) function_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *; RETURNING *;
-- name: UpdateScheduledJob :one -- name: UpdateScheduledJob :one
@@ -30,6 +31,7 @@ SET name = $3,
next_run_at = $7, next_run_at = $7,
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END, secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
payload = CASE WHEN $10::boolean THEN $11 ELSE payload 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() updated_at = NOW()
WHERE id = $1 WHERE id = $1
AND project_id = $2 AND project_id = $2
@@ -147,3 +147,7 @@ WHERE p.organization_id = $1;
DELETE FROM core.organizations DELETE FROM core.organizations
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
-- name: GetOrganizationByID :one
SELECT * FROM core.organizations
WHERE id = $1;
@@ -0,0 +1,36 @@
-- name: ListProjectSecrets :many
SELECT id, project_id, name, url, notes, created_at, updated_at
FROM core.project_secrets
WHERE project_id = $1
ORDER BY name ASC;
-- name: GetProjectSecret :one
SELECT * FROM core.project_secrets
WHERE project_id = $1
AND name = $2;
-- name: UpsertProjectSecret :one
INSERT INTO core.project_secrets (
project_id,
name,
ciphertext,
url,
notes
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, name) DO UPDATE SET
ciphertext = EXCLUDED.ciphertext,
url = EXCLUDED.url,
notes = EXCLUDED.notes,
updated_at = NOW()
RETURNING *;
-- name: DeleteProjectSecret :one
DELETE FROM core.project_secrets
WHERE project_id = $1
AND name = $2
RETURNING id;
-- name: ListProjectSecretValues :many
SELECT name, ciphertext
FROM core.project_secrets
WHERE project_id = $1;
+4 -1
View File
@@ -11,7 +11,10 @@ WHERE id = $1;
SELECT * FROM core.webhooks SELECT * FROM core.webhooks
WHERE project_id = $1 WHERE project_id = $1
AND enabled AND enabled
AND (cardinality(events) = 0 OR $2::text = ANY(events)); AND (cardinality(events) = 0 OR EXISTS (
SELECT 1 FROM unnest(events) AS e
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
));
-- name: CreateWebhook :one -- name: CreateWebhook :one
INSERT INTO core.webhooks ( INSERT INTO core.webhooks (
+30 -2
View File
@@ -81,17 +81,45 @@ func Bootstrap(ctx context.Context) (*App, error) {
} }
} }
store, err := storage.NewLocalStore(cfg.StorageRoot) 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 { if err != nil {
return nil, err return nil, err
} }
store = localStore
}
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir) dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
repo := repositories.NewCoreRepository(dbPool) repo := repositories.NewCoreRepository(dbPool)
encryptor, err := secrets.NewEncryptor(cfg.EncryptionKey) encryptor, err := secrets.NewEncryptor(cfg.EncryptionKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("encryption key: %w", err) return nil, fmt.Errorf("encryption key: %w", err)
} }
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, 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) settings := services.NewSettingsService(repo, encryptor)
if cfg.Env == "production" { if cfg.Env == "production" {
+65
View File
@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type Config struct { type Config struct {
@@ -35,6 +36,26 @@ type Config struct {
SMTPSecure bool SMTPSecure bool
EncryptionKey string EncryptionKey string
// Object storage: "local" (BACKEND_STORAGE_ROOT) or "s3".
StorageDriver string
S3Endpoint string
S3Region string
S3Bucket string
S3AccessKeyID string
S3SecretAccessKey string
S3PublicEndpoint string // presigned-URL host when it differs from Endpoint
S3Prefix string
S3PathStyle bool
// Functions runtime: driver "exec" (runtime binary on the backend host)
// or "docker" (ephemeral locked-down container), binary name/path for
// exec ("auto" resolves per-function runtime from PATH), timeout and
// output cap.
FunctionsDriver string
FunctionsRuntime string
FunctionsTimeout time.Duration
FunctionsMaxBytes int64
} }
// devEncryptionKey is the built-in credential key for local development only. // devEncryptionKey is the built-in credential key for local development only.
@@ -63,6 +84,17 @@ func Load() (Config, error) {
SMTPUser: os.Getenv("SMTP_USER"), SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"), SMTPPassword: os.Getenv("SMTP_PASSWORD"),
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"), EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
StorageDriver: getenv("BACKEND_STORAGE_DRIVER", "local"),
S3Endpoint: os.Getenv("S3_ENDPOINT"),
S3Region: getenv("S3_REGION", "us-east-1"),
S3Bucket: os.Getenv("S3_BUCKET"),
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"),
S3Prefix: os.Getenv("S3_PREFIX"),
FunctionsDriver: getenv("FUNCTIONS_DRIVER", "exec"),
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
FunctionsMaxBytes: 65536,
} }
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025")) smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
@@ -95,6 +127,24 @@ func Load() (Config, error) {
} }
cfg.SMTPSecure = smtpSecure cfg.SMTPSecure = smtpSecure
s3PathStyle, err := strconv.ParseBool(getenv("S3_PATH_STYLE", "true"))
if err != nil {
return Config{}, fmt.Errorf("parse S3_PATH_STYLE: %w", err)
}
cfg.S3PathStyle = s3PathStyle
fnTimeout, err := strconv.Atoi(getenv("FUNCTIONS_TIMEOUT_SECONDS", "30"))
if err != nil {
return Config{}, fmt.Errorf("parse FUNCTIONS_TIMEOUT_SECONDS: %w", err)
}
cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second
switch cfg.FunctionsDriver {
case "exec", "docker":
default:
return Config{}, fmt.Errorf("FUNCTIONS_DRIVER %q must be exec or docker", cfg.FunctionsDriver)
}
var missing []string var missing []string
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {
missing = append(missing, "DATABASE_URL") missing = append(missing, "DATABASE_URL")
@@ -102,9 +152,24 @@ func Load() (Config, error) {
if cfg.JWTSecret == "" { if cfg.JWTSecret == "" {
missing = append(missing, "JWT_SECRET") missing = append(missing, "JWT_SECRET")
} }
switch cfg.StorageDriver {
case "local":
if cfg.StorageRoot == "" { if cfg.StorageRoot == "" {
missing = append(missing, "BACKEND_STORAGE_ROOT") missing = append(missing, "BACKEND_STORAGE_ROOT")
} }
case "s3":
if cfg.S3Bucket == "" {
missing = append(missing, "S3_BUCKET")
}
if cfg.S3AccessKeyID == "" {
missing = append(missing, "S3_ACCESS_KEY_ID")
}
if cfg.S3SecretAccessKey == "" {
missing = append(missing, "S3_SECRET_ACCESS_KEY")
}
default:
return Config{}, fmt.Errorf("unknown BACKEND_STORAGE_DRIVER %q (want local or s3)", cfg.StorageDriver)
}
if cfg.AuthInternalBaseURL == "" { if cfg.AuthInternalBaseURL == "" {
missing = append(missing, "AUTH_INTERNAL_BASE_URL") missing = append(missing, "AUTH_INTERNAL_BASE_URL")
} }
@@ -0,0 +1,290 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: alert_rules.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createAlertRule = `-- name: CreateAlertRule :one
INSERT INTO core.alert_rules (project_id, name, kind, config)
VALUES ($1, $2, $3, $4)
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
`
type CreateAlertRuleParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Config []byte `json:"config"`
}
func (q *Queries) CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) {
row := q.db.QueryRow(ctx, createAlertRule,
arg.ProjectID,
arg.Name,
arg.Kind,
arg.Config,
)
var i CoreAlertRule
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteAlertRule = `-- name: DeleteAlertRule :exec
DELETE FROM core.alert_rules
WHERE id = $1 AND project_id = $2
`
type DeleteAlertRuleParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error {
_, err := q.db.Exec(ctx, deleteAlertRule, arg.ID, arg.ProjectID)
return err
}
const getAlertRule = `-- name: GetAlertRule :one
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
WHERE id = $1 AND project_id = $2
`
type GetAlertRuleParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) {
row := q.db.QueryRow(ctx, getAlertRule, arg.ID, arg.ProjectID)
var i CoreAlertRule
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const lastHeartbeats = `-- name: LastHeartbeats :many
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
FROM core.components co
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
WHERE co.project_id = $1
GROUP BY co.name
`
type LastHeartbeatsRow struct {
ComponentName string `json:"component_name"`
LastBeat pgtype.Timestamptz `json:"last_beat"`
}
// Per-component most recent heartbeat — heartbeat_silence detection.
func (q *Queries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) {
rows, err := q.db.Query(ctx, lastHeartbeats, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []LastHeartbeatsRow
for rows.Next() {
var i LastHeartbeatsRow
if err := rows.Scan(&i.ComponentName, &i.LastBeat); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlertRules = `-- name: ListAlertRules :many
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
WHERE project_id = $1
ORDER BY name
`
func (q *Queries) ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) {
rows, err := q.db.Query(ctx, listAlertRules, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreAlertRule
for rows.Next() {
var i CoreAlertRule
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listEnabledAlertRules = `-- name: ListEnabledAlertRules :many
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
WHERE enabled
`
func (q *Queries) ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) {
rows, err := q.db.Query(ctx, listEnabledAlertRules)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreAlertRule
for rows.Next() {
var i CoreAlertRule
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const recentErrorCounts = `-- name: RecentErrorCounts :many
SELECT co.name AS component_name, count(*) AS errors
FROM core.events e
JOIN core.components co ON co.id = e.component_id
WHERE e.project_id = $1 AND e.type = 'error'
AND e.ts > now() - make_interval(secs => $2::bigint)
GROUP BY co.name
`
type RecentErrorCountsParams struct {
ProjectID uuid.UUID `json:"project_id"`
Column2 int64 `json:"column_2"`
}
type RecentErrorCountsRow struct {
ComponentName string `json:"component_name"`
Errors int64 `json:"errors"`
}
// Per-component error count inside a sliding window — error_spike detection.
func (q *Queries) RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) {
rows, err := q.db.Query(ctx, recentErrorCounts, arg.ProjectID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecentErrorCountsRow
for rows.Next() {
var i RecentErrorCountsRow
if err := rows.Scan(&i.ComponentName, &i.Errors); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setAlertRuleState = `-- name: SetAlertRuleState :exec
UPDATE core.alert_rules
SET state = $3, updated_at = NOW()
WHERE id = $1 AND project_id = $2
`
type SetAlertRuleStateParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
State []byte `json:"state"`
}
func (q *Queries) SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error {
_, err := q.db.Exec(ctx, setAlertRuleState, arg.ID, arg.ProjectID, arg.State)
return err
}
const updateAlertRule = `-- name: UpdateAlertRule :one
UPDATE core.alert_rules
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
`
type UpdateAlertRuleParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Config []byte `json:"config"`
Enabled bool `json:"enabled"`
}
func (q *Queries) UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) {
row := q.db.QueryRow(ctx, updateAlertRule,
arg.ID,
arg.ProjectID,
arg.Name,
arg.Config,
arg.Enabled,
)
var i CoreAlertRule
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -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
}
+22 -8
View File
@@ -22,9 +22,10 @@ INSERT INTO core.scheduled_jobs (
payload, payload,
enabled, enabled,
next_run_at, next_run_at,
created_by_user_id created_by_user_id,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) function_id
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 ) 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 { type CreateScheduledJobParams struct {
@@ -37,6 +38,7 @@ type CreateScheduledJobParams struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"` NextRunAt pgtype.Timestamptz `json:"next_run_at"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"` 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) { func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) {
@@ -50,6 +52,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob
arg.Enabled, arg.Enabled,
arg.NextRunAt, arg.NextRunAt,
arg.CreatedByUserID, arg.CreatedByUserID,
arg.FunctionID,
) )
var i CoreScheduledJob var i CoreScheduledJob
err := row.Scan( err := row.Scan(
@@ -67,6 +70,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob
&i.CreatedByUserID, &i.CreatedByUserID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FunctionID,
) )
return i, err return i, err
} }
@@ -133,7 +137,7 @@ func (q *Queries) FinishScheduledJobRun(ctx context.Context, arg FinishScheduled
} }
const getScheduledJobByID = `-- name: GetScheduledJobByID :one const getScheduledJobByID = `-- name: GetScheduledJobByID :one
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs 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 WHERE id = $1
` `
@@ -155,6 +159,7 @@ func (q *Queries) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreSc
&i.CreatedByUserID, &i.CreatedByUserID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FunctionID,
) )
return i, err return i, err
} }
@@ -188,7 +193,7 @@ func (q *Queries) InsertScheduledJobRun(ctx context.Context, arg InsertScheduled
} }
const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs 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 WHERE enabled
AND next_run_at IS NOT NULL AND next_run_at IS NOT NULL
AND next_run_at <= $1 AND next_run_at <= $1
@@ -218,6 +223,7 @@ func (q *Queries) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Tim
&i.CreatedByUserID, &i.CreatedByUserID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FunctionID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -272,7 +278,7 @@ func (q *Queries) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJob
} }
const listScheduledJobs = `-- name: ListScheduledJobs :many const listScheduledJobs = `-- name: ListScheduledJobs :many
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs 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 WHERE project_id = $1
ORDER BY created_at ASC ORDER BY created_at ASC
` `
@@ -301,6 +307,7 @@ func (q *Queries) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([
&i.CreatedByUserID, &i.CreatedByUserID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FunctionID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -319,7 +326,7 @@ SET last_run_at = $2,
next_run_at = $4, next_run_at = $4,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 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 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 { type MarkScheduledJobRanParams struct {
@@ -352,6 +359,7 @@ func (q *Queries) MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobR
&i.CreatedByUserID, &i.CreatedByUserID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FunctionID,
) )
return i, err return i, err
} }
@@ -386,10 +394,11 @@ SET name = $3,
next_run_at = $7, next_run_at = $7,
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END, secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
payload = CASE WHEN $10::boolean THEN $11 ELSE payload 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() updated_at = NOW()
WHERE id = $1 WHERE id = $1
AND project_id = $2 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 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 { type UpdateScheduledJobParams struct {
@@ -404,6 +413,8 @@ type UpdateScheduledJobParams struct {
Secret []byte `json:"secret"` Secret []byte `json:"secret"`
Column10 bool `json:"column_10"` Column10 bool `json:"column_10"`
Payload []byte `json:"payload"` 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) { func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) {
@@ -419,6 +430,8 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob
arg.Secret, arg.Secret,
arg.Column10, arg.Column10,
arg.Payload, arg.Payload,
arg.Column12,
arg.FunctionID,
) )
var i CoreScheduledJob var i CoreScheduledJob
err := row.Scan( err := row.Scan(
@@ -436,6 +449,7 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob
&i.CreatedByUserID, &i.CreatedByUserID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FunctionID,
) )
return i, err return i, err
} }
@@ -140,6 +140,18 @@ func (ns NullCoreProjectRole) Value() (driver.Value, error) {
return string(ns.CoreProjectRole), nil return string(ns.CoreProjectRole), nil
} }
type CoreAlertRule struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Config []byte `json:"config"`
Enabled bool `json:"enabled"`
State []byte `json:"state"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreApiKey struct { type CoreApiKey struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
@@ -231,6 +243,17 @@ type CoreDocument struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type CoreEmailLog struct {
ID uuid.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
Template string `json:"template"`
ToEmail string `json:"to_email"`
Subject string `json:"subject"`
Status string `json:"status"`
Error string `json:"error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreEvent struct { type CoreEvent struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
@@ -243,6 +266,45 @@ type CoreEvent struct {
Ts pgtype.Timestamptz `json:"ts"` Ts pgtype.Timestamptz `json:"ts"`
} }
type CoreFunction struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Code string `json:"code"`
Runtime string `json:"runtime"`
Enabled bool `json:"enabled"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EventPattern string `json:"event_pattern"`
}
type CoreFunctionRun struct {
ID uuid.UUID `json:"id"`
FunctionID uuid.UUID `json:"function_id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
ExitCode *int32 `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
DurationMs int32 `json:"duration_ms"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreInboundHook struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Token string `json:"token"`
Mode string `json:"mode"`
JobID pgtype.UUID `json:"job_id"`
Secret []byte `json:"secret"`
Enabled bool `json:"enabled"`
LastReceivedAt pgtype.Timestamptz `json:"last_received_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
FunctionID pgtype.UUID `json:"function_id"`
}
type CoreIntegration struct { type CoreIntegration struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
@@ -307,6 +369,17 @@ type CoreProjectMember struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type CoreProjectSecret struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Ciphertext []byte `json:"ciphertext"`
Url string `json:"url"`
Notes string `json:"notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreScheduledJob struct { type CoreScheduledJob struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
@@ -322,6 +395,7 @@ type CoreScheduledJob struct {
CreatedByUserID pgtype.UUID `json:"created_by_user_id"` CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
FunctionID pgtype.UUID `json:"function_id"`
} }
type CoreScheduledJobRun struct { type CoreScheduledJobRun struct {
@@ -241,6 +241,23 @@ func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string
return i, err return i, err
} }
const getOrganizationByID = `-- name: GetOrganizationByID :one
SELECT id, slug, name, created_at FROM core.organizations
WHERE id = $1
`
func (q *Queries) GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) {
row := q.db.QueryRow(ctx, getOrganizationByID, id)
var i CoreOrganization
err := row.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const getOrganizationMembership = `-- name: GetOrganizationMembership :one const getOrganizationMembership = `-- name: GetOrganizationMembership :one
SELECT SELECT
om.id, om.organization_id, om.user_id, om.role, om.created_at, om.id, om.organization_id, om.user_id, om.role, om.created_at,
@@ -0,0 +1,181 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: project_secrets.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteProjectSecret = `-- name: DeleteProjectSecret :one
DELETE FROM core.project_secrets
WHERE project_id = $1
AND name = $2
RETURNING id
`
type DeleteProjectSecretParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error) {
row := q.db.QueryRow(ctx, deleteProjectSecret, arg.ProjectID, arg.Name)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const getProjectSecret = `-- name: GetProjectSecret :one
SELECT id, project_id, name, ciphertext, url, notes, created_at, updated_at FROM core.project_secrets
WHERE project_id = $1
AND name = $2
`
type GetProjectSecretParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error) {
row := q.db.QueryRow(ctx, getProjectSecret, arg.ProjectID, arg.Name)
var i CoreProjectSecret
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Ciphertext,
&i.Url,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listProjectSecretValues = `-- name: ListProjectSecretValues :many
SELECT name, ciphertext
FROM core.project_secrets
WHERE project_id = $1
`
type ListProjectSecretValuesRow struct {
Name string `json:"name"`
Ciphertext []byte `json:"ciphertext"`
}
func (q *Queries) ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error) {
rows, err := q.db.Query(ctx, listProjectSecretValues, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProjectSecretValuesRow
for rows.Next() {
var i ListProjectSecretValuesRow
if err := rows.Scan(&i.Name, &i.Ciphertext); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectSecrets = `-- name: ListProjectSecrets :many
SELECT id, project_id, name, url, notes, created_at, updated_at
FROM core.project_secrets
WHERE project_id = $1
ORDER BY name ASC
`
type ListProjectSecretsRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Url string `json:"url"`
Notes string `json:"notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error) {
rows, err := q.db.Query(ctx, listProjectSecrets, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProjectSecretsRow
for rows.Next() {
var i ListProjectSecretsRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Url,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertProjectSecret = `-- name: UpsertProjectSecret :one
INSERT INTO core.project_secrets (
project_id,
name,
ciphertext,
url,
notes
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, name) DO UPDATE SET
ciphertext = EXCLUDED.ciphertext,
url = EXCLUDED.url,
notes = EXCLUDED.notes,
updated_at = NOW()
RETURNING id, project_id, name, ciphertext, url, notes, created_at, updated_at
`
type UpsertProjectSecretParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Ciphertext []byte `json:"ciphertext"`
Url string `json:"url"`
Notes string `json:"notes"`
}
func (q *Queries) UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error) {
row := q.db.QueryRow(ctx, upsertProjectSecret,
arg.ProjectID,
arg.Name,
arg.Ciphertext,
arg.Url,
arg.Notes,
)
var i CoreProjectSecret
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Ciphertext,
&i.Url,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -23,28 +23,35 @@ type Querier interface {
CountOrganizations(ctx context.Context) (int64, error) CountOrganizations(ctx context.Context) (int64, error)
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error) CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error) CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error)
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error) CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error) CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error) CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error) CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error) CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error)
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error) CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error) CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error) CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error)
CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error) CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error)
DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error) DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error) DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error) DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error) DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error)
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error) DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
DeleteSetting(ctx context.Context, key string) error DeleteSetting(ctx context.Context, key string) error
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error) DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error)
@@ -53,6 +60,7 @@ type Querier interface {
FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error) FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error)
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error) GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error) GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error)
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error) GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error) GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error) GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
@@ -61,13 +69,19 @@ type Querier interface {
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error) GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error) GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error) GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error)
GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error)
GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error)
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error)
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error) GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error) GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error) GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error) GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error) GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error)
GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error)
GetSetting(ctx context.Context, key string) (GetSettingRow, error) GetSetting(ctx context.Context, key string) (GetSettingRow, error)
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error) GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
@@ -75,10 +89,15 @@ type Querier interface {
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error) GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error) GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error) HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error)
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error) InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error)
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error) InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error) InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
// Per-component most recent heartbeat — heartbeat_silence detection.
LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error)
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error) ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error)
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error) ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error) ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error) ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
@@ -88,14 +107,22 @@ type Querier interface {
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error) ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error) ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error)
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error) ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error) ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error)
ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error)
ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error)
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error) ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error) ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error) ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error) ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error) ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error) ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error)
ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error)
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error) ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error)
@@ -110,10 +137,13 @@ type Querier interface {
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error) MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error) MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error
// Per-component error count inside a sliding window — error_spike detection.
RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error)
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error) RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error) RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error) RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error
SweepExpiredAuditLogs(ctx context.Context) (int64, error) SweepExpiredAuditLogs(ctx context.Context) (int64, error)
// Per-project retention sweeps. A retention of 0 days disables the sweep for // 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 // that stream. Pending webhook deliveries are never swept — they may still be
@@ -124,9 +154,12 @@ type Querier interface {
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error) SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error) SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
TouchAPIKey(ctx context.Context, id uuid.UUID) error TouchAPIKey(ctx context.Context, id uuid.UUID) error
TouchInboundHook(ctx context.Context, id uuid.UUID) error
UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error)
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error) UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error)
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error) UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error) UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
@@ -135,6 +168,7 @@ type Querier interface {
UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error) UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error) UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error)
UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error) UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error)
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error) UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
} }
@@ -291,7 +291,10 @@ const listWebhooksForEvent = `-- name: ListWebhooksForEvent :many
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
WHERE project_id = $1 WHERE project_id = $1
AND enabled AND enabled
AND (cardinality(events) = 0 OR $2::text = ANY(events)) AND (cardinality(events) = 0 OR EXISTS (
SELECT 1 FROM unnest(events) AS e
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
))
` `
type ListWebhooksForEventParams struct { type ListWebhooksForEventParams struct {
+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)
}
+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})
}
+132 -6
View File
@@ -2,6 +2,7 @@ package handlers
import ( import (
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@@ -11,7 +12,9 @@ import (
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/middleware" "github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/observability" "github.com/tdvorak/primora/apps/backend/internal/observability"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response" apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
@@ -34,6 +37,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api := router.Group("/api/v1") api := router.Group("/api/v1")
api.GET("/me", h.me) api.GET("/me", h.me)
api.GET("/context", h.actorContext)
api.GET("/instance/public", h.instancePublic) api.GET("/instance/public", h.instancePublic)
api.GET("/instance/settings", h.listInstanceSettings) api.GET("/instance/settings", h.listInstanceSettings)
api.PUT("/instance/settings/:key", h.updateInstanceSetting) api.PUT("/instance/settings/:key", h.updateInstanceSetting)
@@ -78,7 +82,20 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument) api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
api.OPTIONS("/ingest", h.ingestOptions) api.OPTIONS("/ingest", h.ingestOptions)
api.POST("/ingest", h.ingest) api.POST("/ingest", h.ingest)
api.POST("/hooks/:token", h.receiveInboundHook)
api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks)
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
api.GET("/projects/:projectID/emails", h.listEmailLog)
api.GET("/projects/:projectID/functions", h.listFunctions)
api.POST("/projects/:projectID/functions", h.createFunction)
api.GET("/projects/:projectID/functions/:functionID", h.getFunction)
api.PUT("/projects/:projectID/functions/:functionID", h.updateFunction)
api.DELETE("/projects/:projectID/functions/:functionID", h.deleteFunction)
api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction)
api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns)
api.GET("/projects/:projectID/events", h.listEvents) api.GET("/projects/:projectID/events", h.listEvents)
api.POST("/projects/:projectID/events", h.publishEvent)
api.GET("/projects/:projectID/issues", h.listIssues) api.GET("/projects/:projectID/issues", h.listIssues)
api.GET("/projects/:projectID/components", h.listComponents) api.GET("/projects/:projectID/components", h.listComponents)
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent) api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
@@ -116,10 +133,21 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob) api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob)
api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns) api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns)
api.POST("/projects/:projectID/jobs/:jobID/run", h.runJob) 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/stream", h.realtimeStream)
api.GET("/projects/:projectID/realtime/presence", h.realtimePresence)
api.GET("/buckets/:bucketID/objects", h.listObjects) api.GET("/buckets/:bucketID/objects", h.listObjects)
api.POST("/buckets/:bucketID/objects", h.uploadObject) api.POST("/buckets/:bucketID/objects", h.uploadObject)
api.POST("/buckets/:bucketID/object-copies", h.copyObject) api.POST("/buckets/:bucketID/object-copies", h.copyObject)
api.POST("/buckets/:bucketID/object-presigns", h.presignObject)
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject) api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject) api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject) api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
@@ -156,6 +184,19 @@ func (h *HTTPHandler) me(c *gin.Context) {
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }
func (h *HTTPHandler) actorContext(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
result, err := h.Platform.ActorContext(c.Request.Context(), actor)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) bootstrap(c *gin.Context) { func (h *HTTPHandler) bootstrap(c *gin.Context) {
actor, ok := middleware.RequireActor(c) actor, ok := middleware.RequireActor(c)
if !ok { if !ok {
@@ -869,6 +910,43 @@ func (h *HTTPHandler) updateObject(c *gin.Context) {
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
type presignObjectRequest struct {
Key string `json:"key" validate:"required"`
Op string `json:"op" validate:"omitempty,oneof=download upload"`
TTLSeconds int `json:"ttl_seconds" validate:"omitempty,min=60,max=3600"`
}
// presignObject returns a short-lived URL clients call against storage
// directly — keeps large transfers off the backend. S3 driver only.
func (h *HTTPHandler) presignObject(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
bucketID, ok := parseUUIDParam(c, "bucketID")
if !ok {
return
}
var body presignObjectRequest
if !h.bindAndValidate(c, &body) {
return
}
out, err := h.Platform.PresignObject(c.Request.Context(), actor, bucketID, strings.TrimSpace(body.Key), body.Op, body.TTLSeconds)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
return
}
if errors.Is(err, services.ErrPresignUnsupported) {
apperrors.Abort(c, http.StatusBadRequest, "presign_unsupported", err.Error())
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *HTTPHandler) deleteObject(c *gin.Context) { func (h *HTTPHandler) deleteObject(c *gin.Context) {
actor, ok := middleware.RequireActor(c) actor, ok := middleware.RequireActor(c)
if !ok { if !ok {
@@ -1053,12 +1131,19 @@ func (h *HTTPHandler) listDocuments(c *gin.Context) {
return return
} }
actor, _ := middleware.ActorFromContext(c) actor, _ := middleware.ActorFromContext(c)
result, err := h.Platform.ListDocuments(c.Request.Context(), actor, collectionID, limit, offset) var result []db.CoreDocument
if err != nil { var total int64
h.handleError(c, err) var err error
return // PostgREST-style filter/order switches to the dynamic-query path; the
// plain list keeps using the sqlc queries it always has.
if filter := c.Query("filter"); filter != "" || c.Query("order") != "" {
result, total, err = h.Platform.SearchDocuments(c.Request.Context(), actor, collectionID, filter, c.Query("order"), limit, offset)
} else {
result, err = h.Platform.ListDocuments(c.Request.Context(), actor, collectionID, limit, offset)
if err == nil {
total, err = h.Platform.CountDocuments(c.Request.Context(), actor, collectionID)
}
} }
total, err := h.Platform.CountDocuments(c.Request.Context(), actor, collectionID)
if err != nil { if err != nil {
h.handleError(c, err) h.handleError(c, err)
return return
@@ -1195,17 +1280,58 @@ func (h *HTTPHandler) bindAndValidate(c *gin.Context, target any) bool {
return false return false
} }
if err := h.Validate.Struct(target); err != nil { if err := h.Validate.Struct(target); err != nil {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", err.Error()) var valErrs validator.ValidationErrors
if errors.As(err, &valErrs) && len(valErrs) > 0 {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", formatFieldError(valErrs[0]))
} else {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", "invalid request body")
}
return false return false
} }
return true return true
} }
func formatFieldError(f validator.FieldError) string {
if f.Tag() == "required" {
return fmt.Sprintf("%s is required", strings.ToLower(f.Field()))
}
return fmt.Sprintf("%s failed %s validation", strings.ToLower(f.Field()), f.Tag())
}
func (h *HTTPHandler) handleError(c *gin.Context, err error) { func (h *HTTPHandler) handleError(c *gin.Context, err error) {
switch { switch {
case errors.Is(err, io.EOF): case errors.Is(err, io.EOF):
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty") apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
case errors.Is(err, pgx.ErrNoRows):
apperrors.Abort(c, http.StatusNotFound, "not_found", "resource not found")
default: default:
var inputErr *services.InputError
if errors.As(err, &inputErr) {
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", inputErr.Error())
return
}
var valErrs validator.ValidationErrors
if errors.As(err, &valErrs) && len(valErrs) > 0 {
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", formatFieldError(valErrs[0]))
return
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
// Structured codes — never leak raw SQLSTATE text or constraint names.
switch pgErr.Code {
case "23505":
apperrors.Abort(c, http.StatusConflict, "conflict", "resource already exists")
case "23503":
apperrors.Abort(c, http.StatusConflict, "conflict", "referenced resource does not exist")
case "22001", "54000":
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "value too large")
case "22021":
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "input is not valid UTF-8")
default:
apperrors.Abort(c, http.StatusInternalServerError, "request_failed", "database error")
}
return
}
status := http.StatusInternalServerError status := http.StatusInternalServerError
message := strings.ToLower(err.Error()) message := strings.ToLower(err.Error())
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") { if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
@@ -4,9 +4,14 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/tdvorak/primora/apps/backend/internal/services"
) )
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) { func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
@@ -104,4 +109,46 @@ func TestHandleErrorStatusMapping(t *testing.T) {
t.Fatalf("unexpected status: %d", recorder.Code) t.Fatalf("unexpected status: %d", recorder.Code)
} }
}) })
t.Run("maps missing rows to not found", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, pgx.ErrNoRows)
if recorder.Code != http.StatusNotFound {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
t.Run("maps InputError to bad request with message", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, services.NewInputError("webhook url must be https"))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), "webhook url must be https") {
t.Fatalf("expected reason in body, got %s", recorder.Body.String())
}
})
t.Run("maps unique violation to conflict without leaking", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint \"projects_slug_key\""})
if recorder.Code != http.StatusConflict {
t.Fatalf("unexpected status: %d", recorder.Code)
}
if strings.Contains(recorder.Body.String(), "projects_slug_key") || strings.Contains(recorder.Body.String(), "23505") {
t.Fatalf("raw constraint leaked: %s", recorder.Body.String())
}
})
t.Run("maps unknown pg error to internal error", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, &pgconn.PgError{Code: "XX000", Message: "internal"})
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
} }
+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)
}
+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)
}
@@ -27,6 +27,12 @@ func (r *CoreRepository) Queries() *db.Queries {
return r.queries return r.queries
} }
// Pool exposes the pgx pool for dynamic queries sqlc can't express
// (PostgREST-style document filtering builds its WHERE clause at runtime).
func (r *CoreRepository) Pool() *pgxpool.Pool {
return r.pool
}
func (r *CoreRepository) WithTx(ctx context.Context, fn func(*db.Queries) error) error { func (r *CoreRepository) WithTx(ctx context.Context, fn func(*db.Queries) error) error {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
+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)
}
}
}
@@ -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 { if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
t.Error("expected unknown event rejected") t.Error("expected unknown event rejected")
} }
for _, ok := range [][]string{
{"*"},
{"document.*"},
{"custom.deploy.done"},
{"custom.*"},
{"custom.deploy.*"},
} {
if _, err := validateWebhookEvents(ok); err != nil {
t.Errorf("expected %v accepted, got %v", ok, err)
}
}
for _, bad := range [][]string{
{"banana.*"}, {"custom..*"}, {"custom.UPPER"}, {"document.exploded"},
} {
if _, err := validateWebhookEvents(bad); err == nil {
t.Errorf("expected %v rejected", bad)
}
}
}
func TestPrivateWebhookHostHardening(t *testing.T) {
private := []string{"localhost", "minio", "echo-svc", "host.docker.internal",
"10.0.0.4", "172.16.5.5", "192.168.1.10", "fe80::1", "svc.local", "db.internal"}
public := []string{"169.254.169.254", "fd00:ec2::254", "2130706433", "0x7f000001",
"8.8.8.8", "example.com", "999999999999"}
for _, h := range private {
if !isPrivateWebhookHost(h) {
t.Errorf("expected %q private", h)
}
}
for _, h := range public {
if isPrivateWebhookHost(h) {
t.Errorf("expected %q public/blocked", h)
}
}
} }
+77 -5
View File
@@ -15,6 +15,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
db "github.com/tdvorak/primora/apps/backend/internal/database/db" db "github.com/tdvorak/primora/apps/backend/internal/database/db"
@@ -34,6 +35,7 @@ type ScheduledJobSummary struct {
Name string `json:"name"` Name string `json:"name"`
Schedule string `json:"schedule"` Schedule string `json:"schedule"`
URL string `json:"url"` URL string `json:"url"`
FunctionID *string `json:"function_id,omitempty"`
Payload json.RawMessage `json:"payload"` Payload json.RawMessage `json:"payload"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
HasSecret bool `json:"has_secret"` HasSecret bool `json:"has_secret"`
@@ -60,7 +62,8 @@ type ScheduledJobRunSummary struct {
type CreateScheduledJobInput struct { type CreateScheduledJobInput struct {
Name string `json:"name" validate:"required,min=2"` Name string `json:"name" validate:"required,min=2"`
Schedule string `json:"schedule" validate:"required"` Schedule string `json:"schedule" validate:"required"`
URL string `json:"url" validate:"required,url"` URL string `json:"url" validate:"required_without=FunctionID,omitempty,url"`
FunctionID string `json:"function_id" validate:"omitempty,uuid"`
Secret string `json:"secret"` Secret string `json:"secret"`
Payload map[string]any `json:"payload"` Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
@@ -70,6 +73,7 @@ type UpdateScheduledJobInput struct {
Name *string `json:"name" validate:"omitempty,min=2"` Name *string `json:"name" validate:"omitempty,min=2"`
Schedule *string `json:"schedule"` Schedule *string `json:"schedule"`
URL *string `json:"url" validate:"omitempty,url"` URL *string `json:"url" validate:"omitempty,url"`
FunctionID *string `json:"function_id" validate:"omitempty,uuid"`
Secret *string `json:"secret"` Secret *string `json:"secret"`
Payload map[string]any `json:"payload"` Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
@@ -87,6 +91,10 @@ func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
HasSecret: len(row.Secret) > 0, HasSecret: len(row.Secret) > 0,
CreatedAt: row.CreatedAt.Time, CreatedAt: row.CreatedAt.Time,
} }
if row.FunctionID.Valid {
id := uuid.UUID(row.FunctionID.Bytes).String()
out.FunctionID = &id
}
if row.LastRunAt.Valid { if row.LastRunAt.Valid {
t := row.LastRunAt.Time t := row.LastRunAt.Time
out.LastRunAt = &t out.LastRunAt = &t
@@ -175,10 +183,26 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return ScheduledJobSummary{}, err return ScheduledJobSummary{}, err
} }
jobURL, err := normalizeWebhookURL(input.URL) 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 { if err != nil {
return ScheduledJobSummary{}, err return ScheduledJobSummary{}, err
} }
}
sched, err := parseSchedule(input.Schedule) sched, err := parseSchedule(input.Schedule)
if err != nil { if err != nil {
return ScheduledJobSummary{}, err return ScheduledJobSummary{}, err
@@ -221,6 +245,7 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
Enabled: enabled, Enabled: enabled,
NextRunAt: next, NextRunAt: next,
CreatedByUserID: createdBy, CreatedByUserID: createdBy,
FunctionID: functionID,
}) })
if err != nil { if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") { if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
@@ -259,6 +284,28 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
return ScheduledJobSummary{}, err 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 enabled := row.Enabled
if input.Enabled != nil { if input.Enabled != nil {
enabled = *input.Enabled enabled = *input.Enabled
@@ -301,6 +348,8 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
Secret: secretBytes, Secret: secretBytes,
Column10: rotatePayload, Column10: rotatePayload,
Payload: payload, Payload: payload,
Column12: rotateFunction,
FunctionID: functionID,
}) })
if err != nil { if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") { if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
@@ -393,6 +442,7 @@ type jobQueries interface {
FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error) FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error)
MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error) MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error)
PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]db.ListProjectSecretValuesRow, error)
} }
type JobScheduler struct { type JobScheduler struct {
@@ -405,8 +455,13 @@ type JobScheduler struct {
quit chan struct{} quit chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
started sync.Once started sync.Once
leader *leaderState
// onEvent fans a finished run out to webhooks and the realtime stream. // 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) 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 { type jobExec struct {
@@ -414,7 +469,7 @@ type jobExec struct {
run db.CoreScheduledJobRun run db.CoreScheduledJobRun
} }
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)) *JobScheduler { 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 { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
@@ -427,6 +482,7 @@ func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger,
queue: make(chan jobExec, 256), queue: make(chan jobExec, 256),
quit: make(chan struct{}), quit: make(chan struct{}),
onEvent: onEvent, onEvent: onEvent,
leader: newLeaderState(pool, leaderKeyScheduler),
} }
} }
@@ -451,8 +507,13 @@ func (j *JobScheduler) run(ctx context.Context) {
case exec := <-j.queue: case exec := <-j.queue:
j.execute(exec.job, exec.run) j.execute(exec.job, exec.run)
case <-ticker.C: 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() j.scan()
}
case <-j.quit: case <-j.quit:
j.leader.stop(context.Background())
return return
case <-ctx.Done(): case <-ctx.Done():
return return
@@ -550,8 +611,13 @@ func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, sta
} }
} }
// deliver POSTs the job payload to its URL, signed like a webhook delivery. // 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) { 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{ payload, _ := json.Marshal(map[string]any{
"event": WebhookEventJobRun, "event": WebhookEventJobRun,
"project_id": job.ProjectID.String(), "project_id": job.ProjectID.String(),
@@ -560,8 +626,14 @@ func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run
"run_id": run.ID.String(), "run_id": run.ID.String(),
"triggered_by": run.TriggeredBy, "triggered_by": run.TriggeredBy,
"occurred_at": time.Now().UTC().Format(time.RFC3339), "occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": json.RawMessage(job.Payload), "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 := "" secret := ""
if len(job.Secret) > 0 && j.enc != nil { if len(job.Secret) > 0 && j.enc != nil {
if plain, err := j.enc.Decrypt(job.Secret); err == nil { if plain, err := j.enc.Decrypt(job.Secret); err == 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" "github.com/tdvorak/primora/apps/backend/internal/config"
) )
// Mailer is transport-only: Resend when configured, plain SMTP otherwise.
// Templates render (subject, body) from data; sends are logged to
// core.email_log by the service layer so operators can see what went out.
type Mailer struct { type Mailer struct {
cfg config.Config cfg config.Config
resend *resend.Client resend *resend.Client
@@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer {
return &Mailer{cfg: cfg, resend: resendClient} return &Mailer{cfg: cfg, resend: resendClient}
} }
func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error { // mailTemplate renders a subject + plaintext body from template data.
subject := fmt.Sprintf("You were invited to %s on Primora", organizationName) type mailTemplate func(data map[string]string) (subject string, body string)
text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n"
var mailTemplates = map[string]mailTemplate{
"invitation": func(d map[string]string) (string, string) {
subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"])
body := fmt.Sprintf(
"You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n",
d["invite_url"],
)
return subject, body
},
}
func renderMailTemplate(template string, data map[string]string) (string, string, error) {
tpl, ok := mailTemplates[template]
if !ok {
return "", "", fmt.Errorf("unknown email template %q", template)
}
subject, body := tpl(data)
return subject, body, nil
}
func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error {
if m.resend != nil { if m.resend != nil {
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{ _, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
From: m.cfg.MailFrom, From: m.cfg.MailFrom,
@@ -0,0 +1,37 @@
package services
import (
"strings"
"testing"
)
func TestRenderMailTemplateInvitation(t *testing.T) {
subject, body, err := renderMailTemplate("invitation", map[string]string{
"organization": "Acme Corp",
"invite_url": "https://app.primora.dev/accept?token=abc",
})
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(subject, "Acme Corp") {
t.Fatalf("subject missing organization: %q", subject)
}
if !strings.Contains(body, "https://app.primora.dev/accept?token=abc") {
t.Fatalf("body missing invite url: %q", body)
}
}
func TestRenderMailTemplateUnknown(t *testing.T) {
if _, _, err := renderMailTemplate("does-not-exist", nil); err == nil {
t.Fatal("expected error for unknown template")
}
}
func TestExtractEmail(t *testing.T) {
if got := extractEmail("Primora <[email protected]>"); got != "[email protected]" {
t.Fatalf("extractEmail = %q", got)
}
if got := extractEmail("[email protected]"); got != "[email protected]" {
t.Fatalf("extractEmail bare = %q", got)
}
}
@@ -10,9 +10,12 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/http"
"net/url" "net/url"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"sync"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -29,7 +32,7 @@ import (
type PlatformService struct { type PlatformService struct {
repo *repositories.CoreRepository repo *repositories.CoreRepository
store *storage.LocalStore store storage.Store
mailer *Mailer mailer *Mailer
publicURL string publicURL string
dbx *dbx.Client dbx *dbx.Client
@@ -38,6 +41,13 @@ type PlatformService struct {
enc *secrets.Encryptor enc *secrets.Encryptor
dispatcher *WebhookDispatcher dispatcher *WebhookDispatcher
scheduler *JobScheduler scheduler *JobScheduler
alerts *alertEvaluator
functions FunctionRunner
logger *slog.Logger
// presenceMu guards presence: realtime subscriber counts per project.
presenceMu sync.Mutex
presence map[uuid.UUID]int
} }
type BootstrapInput struct { type BootstrapInput struct {
@@ -223,17 +233,84 @@ type InvitationSummary struct {
Status string `json:"status"` Status string `json:"status"`
} }
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService { func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *PlatformService {
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc} s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions, logger: logger, presence: map[uuid.UUID]int{}}
if enc != nil { if enc != nil {
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger) s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
s.dispatcher.Start(context.Background()) s.dispatcher.Start(context.Background())
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent) s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent, repo.Pool())
s.scheduler.runFn = s.runJobFunction
s.scheduler.Start(context.Background()) s.scheduler.Start(context.Background())
s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool())
s.alerts.Start(context.Background())
} }
return s return s
} }
// 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. // Close stops the webhook dispatcher and job scheduler. Idempotent.
func (s *PlatformService) Close() { func (s *PlatformService) Close() {
if s.dispatcher != nil { if s.dispatcher != nil {
@@ -242,6 +319,9 @@ func (s *PlatformService) Close() {
if s.scheduler != nil { if s.scheduler != nil {
s.scheduler.Stop() s.scheduler.Stop()
} }
if s.alerts != nil {
s.alerts.Stop()
}
} }
// publishEvent fans a domain event out to realtime SSE subscribers and to // publishEvent fans a domain event out to realtime SSE subscribers and to
@@ -256,18 +336,118 @@ func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID,
}) })
} }
s.dispatchEvent(ctx, projectID, eventType, data) s.dispatchEvent(ctx, projectID, eventType, data)
s.triggerFunctionsForEvent(ctx, projectID, eventType, data)
} }
// SubscribeRealtime registers an SSE consumer for project domain events. // 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) { 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 { if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err return nil, err
} }
return s.realtime.Subscribe(projectID.String()), nil ch := s.realtime.Subscribe(projectID.String())
s.bumpPresence(projectID, 1)
return ch, nil
} }
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) { func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
s.realtime.Unsubscribe(projectID.String(), ch) s.realtime.Unsubscribe(projectID.String(), ch)
s.bumpPresence(projectID, -1)
}
// bumpPresence adjusts the online count and broadcasts presence.update to
// realtime subscribers only — deliberately not through publishEvent, so
// webhooks and `event_pattern: "*"` functions don't fire on connect noise.
func (s *PlatformService) bumpPresence(projectID uuid.UUID, delta int) {
s.presenceMu.Lock()
n := s.presence[projectID] + delta
if n <= 0 {
delete(s.presence, projectID)
n = 0
} else {
s.presence[projectID] = n
}
s.presenceMu.Unlock()
s.realtime.Broadcast(projectID.String(), map[string]any{
"type": "presence.update",
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": map[string]any{"online": n},
})
}
// Presence returns the current realtime subscriber count for a project.
func (s *PlatformService) Presence(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (int, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return 0, err
}
s.presenceMu.Lock()
defer s.presenceMu.Unlock()
return s.presence[projectID], nil
}
var customEventTypeRe = regexp.MustCompile(`^custom\.[a-z0-9][a-z0-9_.-]{0,62}$`)
// PublishProjectEvent lets a member emit a custom domain event — realtime
// subscribers, matching webhooks, and event_pattern functions all fire via
// the standard publishEvent fan-out. The custom.* prefix is enforced so
// clients can't spoof system event types (document.created etc.).
func (s *PlatformService) PublishProjectEvent(ctx context.Context, actor *models.Actor, projectID uuid.UUID, eventType string, data map[string]any) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return err
}
if !customEventTypeRe.MatchString(eventType) {
return errors.New("invalid event type: must match custom.<name> (lowercase letters, digits, dots, dashes)")
}
if data == nil {
data = map[string]any{}
}
s.publishEvent(ctx, projectID, eventType, data)
return nil
}
// ActorContext describes the caller's own scope — the self-discovery
// endpoint for API keys, which cannot call /me. User actors get just the
// type marker; their full view is PlatformSummary.
type ActorContext struct {
Actor string `json:"actor"`
Scopes []string `json:"scopes,omitempty"`
KeyPrefix string `json:"key_prefix,omitempty"`
Organization *ContextOrgInfo `json:"organization,omitempty"`
Project *ContextRef `json:"project,omitempty"`
}
type ContextOrgInfo struct {
ContextRef
Role string `json:"role,omitempty"`
}
type ContextRef struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
}
func (s *PlatformService) ActorContext(ctx context.Context, actor *models.Actor) (ActorContext, error) {
out := ActorContext{Actor: string(actor.Type)}
if actor.Type != models.ActorTypeAPIKey {
return out, nil
}
out.Scopes = actor.Scopes
out.KeyPrefix = actor.APIKeyPrefix
if actor.ProjectID == nil {
return out, nil
}
project, err := s.repo.Queries().GetProjectByID(ctx, *actor.ProjectID)
if err != nil {
return out, err
}
out.Project = &ContextRef{ID: project.ID, Slug: project.Slug, Name: project.Name}
org, err := s.repo.Queries().GetOrganizationByID(ctx, project.OrganizationID)
if err != nil {
return out, err
}
out.Organization = &ContextOrgInfo{ContextRef: ContextRef{ID: org.ID, Slug: org.Slug, Name: org.Name}}
return out, nil
} }
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) { func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
@@ -907,7 +1087,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac
if err != nil { if err != nil {
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
} }
if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil { var inviteProject *uuid.UUID
if projectUUID.Valid {
id := uuid.UUID(projectUUID.Bytes)
inviteProject = &id
}
if err := s.sendTemplatedEmail(ctx, inviteProject, "invitation", invitation.Email, map[string]string{
"organization": "Primora",
"invite_url": inviteURL,
}); err != nil {
return nil, err return nil, err
} }
return map[string]any{ return map[string]any{
@@ -1336,6 +1524,64 @@ func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bu
return object, file, nil return object, file, nil
} }
// ErrPresignUnsupported — the configured storage driver can't mint signed
// URLs (only the S3 driver can). Handlers map it to 400, not 500.
var ErrPresignUnsupported = errors.New("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
// InputError marks a service-layer failure caused by caller input — the
// HTTP layer maps it to 400 instead of 500.
type InputError struct{ msg string }
func (e *InputError) Error() string { return e.msg }
func NewInputError(msg string) error { return &InputError{msg: msg} }
func inputErrorf(format string, args ...any) error {
return &InputError{msg: fmt.Sprintf(format, args...)}
}
// PresignObject mints a short-lived direct-to-storage URL for a single
// object. Requires the s3 storage driver — the local store has no external
// URL surface to sign against.
func (s *PlatformService) PresignObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, op string, ttlSeconds int) (*storage.PresignedURL, error) {
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return nil, err
}
method := ""
switch op {
case "download", "":
method = http.MethodGet
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
return nil, err
}
if !objectExists(ctx, s, bucketID, objectKey) {
return nil, pgx.ErrNoRows
}
case "upload":
method = http.MethodPut
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("invalid op %q — download or upload", op)
}
p, ok := s.store.(storage.Presigner)
if !ok {
return nil, ErrPresignUnsupported
}
ttl := time.Duration(ttlSeconds) * time.Second
return p.Presign(ctx, bucketID.String(), objectKey, method, ttl)
}
func objectExists(ctx context.Context, s *PlatformService, bucketID uuid.UUID, key string) bool {
_, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
BucketID: bucketID,
ObjectKey: key,
})
return err == nil
}
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) { func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID) sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil { if err != nil {
@@ -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
}
}
@@ -0,0 +1,125 @@
package services
import (
"context"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"github.com/google/uuid"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
)
func testEncryptor(t *testing.T) *secrets.Encryptor {
t.Helper()
key := base64.StdEncoding.EncodeToString(make([]byte, 32))
enc, err := secrets.NewEncryptor(key)
if err != nil {
t.Fatalf("NewEncryptor: %v", err)
}
return enc
}
func secretLister(t *testing.T, enc *secrets.Encryptor, kv map[string]string) func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error) {
t.Helper()
return func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error) {
rows := make([]db.ListProjectSecretValuesRow, 0, len(kv))
for name, value := range kv {
ct, err := enc.Encrypt([]byte(value))
if err != nil {
t.Fatalf("encrypt %s: %v", name, err)
}
rows = append(rows, db.ListProjectSecretValuesRow{Name: name, Ciphertext: ct})
}
return rows, nil
}
}
func TestResolveSecretRefs(t *testing.T) {
enc := testEncryptor(t)
list := secretLister(t, enc, map[string]string{
"API_KEY": "sk_live_99",
"RECURSIVE": "secret://API_KEY", // must not resolve twice
})
pid := uuid.New()
t.Run("replaces nested and array refs", func(t *testing.T) {
payload := json.RawMessage(`{
"token": "secret://API_KEY",
"nested": {"deep": ["a", "secret://API_KEY", {"x": "secret://API_KEY"}]},
"plain": "not a secret",
"num": 42
}`)
out, err := resolveSecretRefs(context.Background(), list, enc, pid, payload)
if err != nil {
t.Fatalf("resolve: %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if doc["token"] != "sk_live_99" {
t.Errorf("token = %v", doc["token"])
}
deep := doc["nested"].(map[string]any)["deep"].([]any)
if deep[1] != "sk_live_99" || deep[2].(map[string]any)["x"] != "sk_live_99" {
t.Errorf("nested refs not resolved: %v", deep)
}
if doc["plain"] != "not a secret" || doc["num"] != float64(42) {
t.Errorf("non-ref values changed: %v", doc)
}
})
t.Run("missing ref fails the job", func(t *testing.T) {
_, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"secret://NOPE"}`))
if err == nil || !strings.Contains(err.Error(), "NOPE") {
t.Fatalf("expected unresolved error naming NOPE, got %v", err)
}
})
t.Run("embedded ref in a larger string is not resolved", func(t *testing.T) {
out, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"prefix secret://API_KEY suffix"}`))
if err != nil {
t.Fatalf("resolve: %v", err)
}
if !strings.Contains(string(out), "secret://API_KEY") {
t.Fatalf("embedded ref must pass through unchanged: %s", out)
}
})
t.Run("resolved value is not re-scanned", func(t *testing.T) {
out, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"secret://RECURSIVE"}`))
if err != nil {
t.Fatalf("resolve: %v", err)
}
var doc map[string]any
_ = json.Unmarshal(out, &doc)
if doc["a"] != "secret://API_KEY" {
t.Fatalf("single-pass resolution expected, got %v", doc["a"])
}
})
t.Run("invalid JSON fails loudly when a ref is present", func(t *testing.T) {
_, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{secret://API_KEY`))
if err == nil {
t.Fatal("expected JSON error")
}
})
t.Run("payloads without refs pass through untouched", func(t *testing.T) {
payload := json.RawMessage(`{"a":1}`)
out, err := resolveSecretRefs(context.Background(), list, enc, pid, payload)
if err != nil || string(out) != string(payload) {
t.Fatalf("passthrough failed: %v %s", err, out)
}
// Nil encryptor must fail loudly — a literal secret:// reaching the
// endpoint is worse than a failed run.
_, err = resolveSecretRefs(context.Background(), list, nil, pid, json.RawMessage(`{"a":"secret://API_KEY"}`))
if err == nil {
t.Fatal("expected error when encryption is not configured")
}
})
}
+53 -5
View File
@@ -14,6 +14,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -31,6 +32,8 @@ const (
WebhookEventIssueCreated = "issue.created" WebhookEventIssueCreated = "issue.created"
WebhookEventDeployMarker = "deploy.marker" WebhookEventDeployMarker = "deploy.marker"
WebhookEventTest = "webhook.test" WebhookEventTest = "webhook.test"
WebhookEventAlertFired = "alert.fired"
WebhookEventAlertResolved = "alert.resolved"
WebhookEventDocumentCreated = "document.created" WebhookEventDocumentCreated = "document.created"
WebhookEventDocumentUpdated = "document.updated" WebhookEventDocumentUpdated = "document.updated"
WebhookEventDocumentDeleted = "document.deleted" WebhookEventDocumentDeleted = "document.deleted"
@@ -43,6 +46,9 @@ var webhookEventTypes = map[string]bool{
WebhookEventIssueCreated: true, WebhookEventIssueCreated: true,
WebhookEventDeployMarker: true, WebhookEventDeployMarker: true,
WebhookEventTest: true, WebhookEventTest: true,
WebhookEventAlertFired: true,
WebhookEventAlertResolved: true,
WebhookEventInboundReceived: true,
WebhookEventJobRun: true, WebhookEventJobRun: true,
WebhookEventDocumentCreated: true, WebhookEventDocumentCreated: true,
WebhookEventDocumentUpdated: true, WebhookEventDocumentUpdated: true,
@@ -160,7 +166,7 @@ func normalizeWebhookURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw) trimmed := strings.TrimSpace(raw)
u, err := url.Parse(trimmed) u, err := url.Parse(trimmed)
if err != nil || u.Host == "" { if err != nil || u.Host == "" {
return "", errors.New("webhook url must be an https URL") return "", inputErrorf("webhook url must be an https URL")
} }
if u.Scheme == "https" { if u.Scheme == "https" {
return trimmed, nil return trimmed, nil
@@ -168,17 +174,35 @@ func normalizeWebhookURL(raw string) (string, error) {
if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) { if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) {
return trimmed, nil return trimmed, nil
} }
return "", errors.New("webhook url must be https (http allowed only for private/self-hosted targets)") return "", inputErrorf("webhook url must be https (http allowed only for private/self-hosted targets)")
}
var decimalOrHexIPRe = regexp.MustCompile(`^(0x[0-9a-f]+|[0-9]+)$`)
// metadataAddrs are link-local addresses that serve cloud instance
// metadata — "private" by range but a privileged SSRF target.
var metadataAddrs = map[string]bool{
"169.254.169.254": true, // AWS/GCP/Azure
"fd00:ec2::254": true, // AWS IPv6 metadata
} }
func isPrivateWebhookHost(host string) bool { func isPrivateWebhookHost(host string) bool {
h := strings.ToLower(strings.Trim(host, "[]")) h := strings.ToLower(strings.Trim(host, "[]"))
if metadataAddrs[h] {
return false
}
if h == "localhost" || h == "host.docker.internal" || if h == "localhost" || h == "host.docker.internal" ||
strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") { strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") {
return true return true
} }
if !strings.Contains(h, ".") && !strings.Contains(h, ":") { if !strings.Contains(h, ".") && !strings.Contains(h, ":") {
return true // bare hostname — docker service name, unqualified LAN host // Bare hostname — docker service name, unqualified LAN host. A
// pure decimal/hex integer is never a service name: it's a
// non-canonical IP encoding (2130706433 = 127.0.0.1, 0x7f000001).
if decimalOrHexIPRe.MatchString(h) {
return false
}
return true
} }
ip := net.ParseIP(h) ip := net.ParseIP(h)
if ip == nil { if ip == nil {
@@ -187,16 +211,40 @@ func isPrivateWebhookHost(host string) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
} }
// webhookEventNamespaces are the valid `<ns>.*` subscription prefixes —
// derived from the known system event types.
func webhookEventNamespaces() map[string]bool {
ns := map[string]bool{}
for t := range webhookEventTypes {
if i := strings.Index(t, "."); i > 0 {
ns[t[:i]] = true
}
}
return ns
}
// validateWebhookEvents accepts known system types, `*`, `<ns>.*` namespace
// patterns, and `custom.*` wildcard patterns — the same grammar functions
// use for event_pattern. Matching happens via SQL LIKE at dispatch.
func validateWebhookEvents(events []string) ([]string, error) { func validateWebhookEvents(events []string) ([]string, error) {
out := make([]string, 0, len(events)) out := make([]string, 0, len(events))
seen := map[string]bool{} seen := map[string]bool{}
namespaces := webhookEventNamespaces()
for _, e := range events { for _, e := range events {
e = strings.TrimSpace(e) e = strings.TrimSpace(e)
if e == "" { if e == "" {
continue continue
} }
if !webhookEventTypes[e] { valid := webhookEventTypes[e] || e == "*"
return nil, fmt.Errorf("unknown event type %q", e) if !valid && strings.HasSuffix(e, ".*") {
valid = namespaces[strings.TrimSuffix(e, ".*")]
}
if !valid && strings.HasPrefix(e, "custom.") {
// `*` isn't in the type grammar — probe it as a literal.
valid = customEventTypeRe.MatchString(strings.ReplaceAll(e, "*", "x"))
}
if !valid {
return nil, inputErrorf("invalid event type %q — use a known type, <namespace>.*, custom.<pattern>, or *", e)
} }
if !seen[e] { if !seen[e] {
seen[e] = true seen[e] = true
+1 -1
View File
@@ -65,7 +65,7 @@ func (s *LocalStore) Put(ctx context.Context, bucketID, objectKey string, reader
}, nil }, nil
} }
func (s *LocalStore) Open(bucketID, objectKey string) (*os.File, string, error) { func (s *LocalStore) Open(bucketID, objectKey string) (io.ReadCloser, string, error) {
cleanKey, err := sanitizeObjectKey(objectKey) cleanKey, err := sanitizeObjectKey(objectKey)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
+572
View File
@@ -0,0 +1,572 @@
package storage
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"sort"
"strings"
"time"
)
// S3Config configures an S3-compatible object store (AWS S3, MinIO,
// Cloudflare R2, Garage, …). Endpoint may be empty for AWS, in which case
// the standard regional endpoint is derived from Region + Bucket.
type S3Config struct {
Endpoint string // e.g. http://localhost:9000 or https://s3.eu-central-1.amazonaws.com
Region string
Bucket string
AccessKeyID string
SecretAccessKey string
Prefix string // optional key prefix, e.g. "primora/"
PathStyle bool // bucket/key path style (MinIO, Garage) vs virtual-hosted
// PublicEndpoint rewrites presigned-URL hosts for clients that can't
// reach Endpoint (e.g. minio:9000 inside compose). Signature binds the
// public host — the URL is only valid there.
PublicEndpoint string
HTTPClient *http.Client
}
// S3Store implements Store against an S3-compatible API using stdlib-only
// SigV4 signing — no AWS SDK dependency. Payloads are sent with
// x-amz-content-sha256=UNSIGNED-PAYLOAD so bodies stream without buffering;
// the SHA-256 for PutResult is computed on a hashing tee instead.
// jarvis: ceiling UNSIGNED-PAYLOAD needs TLS on AWS proper; over plain
// http:// it relies on the server accepting it (MinIO/Garage/LocalStack do).
// Upgrade to aws-chunked streaming signatures if a backend rejects it.
type S3Store struct {
cfg S3Config
client *http.Client
now func() time.Time // injectable for tests
}
func NewS3Store(cfg S3Config) (*S3Store, error) {
if cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
return nil, fmt.Errorf("s3 storage requires S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY")
}
if cfg.Region == "" {
cfg.Region = "us-east-1"
}
if cfg.Endpoint == "" {
cfg.Endpoint = fmt.Sprintf("https://s3.%s.amazonaws.com", cfg.Region)
}
client := cfg.HTTPClient
if client == nil {
client = &http.Client{Timeout: 5 * time.Minute}
}
return &S3Store{cfg: cfg, client: client, now: time.Now}, nil
}
func escapeKey(key string) string {
parts := strings.Split(key, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "/")
}
func (s *S3Store) Put(ctx context.Context, bucketID, objectKey string, reader io.Reader) (PutResult, error) {
key, err := s.objectKey(bucketID, objectKey)
if err != nil {
return PutResult{}, err
}
// S3 requires a Content-Length — chunked transfer encoding is rejected.
// Seekable readers (uploaded *os.File) give the length for free;
// anything else is spooled to a temp file, same cost the local driver
// pays on every write anyway.
body, length, cleanup, err := bodyWithLength(reader)
if err != nil {
return PutResult{}, err
}
defer cleanup()
hasher := sha256.New()
counter := &countingReader{r: io.TeeReader(body, hasher)}
req, err := s.signedRequest(ctx, http.MethodPut, key, counter)
if err != nil {
return PutResult{}, err
}
req.ContentLength = length
resp, err := s.client.Do(req)
if err != nil {
return PutResult{}, fmt.Errorf("s3 put: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return PutResult{}, fmt.Errorf("s3 put: %s: %s", resp.Status, readErrBody(resp))
}
return PutResult{
Path: fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, key),
SizeBytes: counter.n,
SHA256Digest: hex.EncodeToString(hasher.Sum(nil)),
}, nil
}
// bodyWithLength returns a reader plus its total length. The cleanup func
// removes any temp file it created.
func bodyWithLength(r io.Reader) (io.Reader, int64, func(), error) {
nop := func() {}
if seeker, ok := r.(io.Seeker); ok {
size, err := seeker.Seek(0, io.SeekEnd)
if err != nil {
return nil, 0, nil, fmt.Errorf("seek object body: %w", err)
}
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
return nil, 0, nil, fmt.Errorf("rewind object body: %w", err)
}
return r, size, nop, nil
}
tmp, err := os.CreateTemp("", "primora-s3-put-*")
if err != nil {
return nil, 0, nil, fmt.Errorf("spool object body: %w", err)
}
size, err := io.Copy(tmp, r)
if err != nil {
tmp.Close()
os.Remove(tmp.Name())
return nil, 0, nil, fmt.Errorf("spool object body: %w", err)
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
tmp.Close()
os.Remove(tmp.Name())
return nil, 0, nil, err
}
cleanup := func() {
tmp.Close()
os.Remove(tmp.Name())
}
return tmp, size, cleanup, nil
}
type countingReader struct {
r io.Reader
n int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}
func (s *S3Store) Open(bucketID, objectKey string) (io.ReadCloser, string, error) {
key, err := s.objectKey(bucketID, objectKey)
if err != nil {
return nil, "", err
}
req, err := s.signedRequest(context.Background(), http.MethodGet, key, nil)
if err != nil {
return nil, "", err
}
resp, err := s.client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("s3 get: %w", err)
}
if resp.StatusCode/100 != 2 {
defer resp.Body.Close()
return nil, "", fmt.Errorf("s3 get: %s: %s", resp.Status, readErrBody(resp))
}
return resp.Body, fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, key), nil
}
func (s *S3Store) Delete(bucketID, objectKey string) error {
key, err := s.objectKey(bucketID, objectKey)
if err != nil {
return err
}
req, err := s.signedRequest(context.Background(), http.MethodDelete, key, nil)
if err != nil {
return err
}
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("s3 delete: %w", err)
}
defer resp.Body.Close()
// S3 delete is idempotent: 204 on success, 404 is fine too.
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
return fmt.Errorf("s3 delete: %s: %s", resp.Status, readErrBody(resp))
}
return nil
}
func (s *S3Store) MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error) {
src, err := s.objectKey(sourceBucketID, fromKey)
if err != nil {
return "", err
}
dst, err := s.objectKey(destinationBucketID, toKey)
if err != nil {
return "", err
}
// x-amz-copy-source must be set before signing — strict S3
// implementations require all x-amz-* headers to be signed.
key := dst
u := strings.TrimSuffix(s.cfg.Endpoint, "/") + "/" + s.cfg.Bucket + "/" + escapeKey(key)
if !s.cfg.PathStyle {
parsed, perr := url.Parse(strings.TrimSuffix(s.cfg.Endpoint, "/"))
if perr != nil {
return "", perr
}
parsed.Host = s.cfg.Bucket + "." + parsed.Host
parsed.Path = "/" + escapeKey(key)
u = parsed.String()
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, u, nil)
if err != nil {
return "", err
}
req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
req.Header.Set("x-amz-copy-source", "/"+s.cfg.Bucket+"/"+escapeKey(src))
s.sign(req)
resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("s3 copy: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("s3 copy: %s: %s", resp.Status, readErrBody(resp))
}
if err := s.Delete(sourceBucketID, fromKey); err != nil {
return "", fmt.Errorf("s3 move: delete source: %w", err)
}
return fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, dst), nil
}
// DeleteBucket lists every object under the bucket prefix and deletes them
// one by one — DeleteObjects batching is an optimization not worth the code
// at this scale.
func (s *S3Store) DeleteBucket(bucketID string) error {
prefix := path.Join(s.cfg.Prefix, bucketID) + "/"
continuation := ""
for {
keys, next, err := s.listPage(prefix, continuation)
if err != nil {
return err
}
for _, k := range keys {
req, err := s.signedRequest(context.Background(), http.MethodDelete, k, nil)
if err != nil {
return err
}
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("s3 delete %s: %w", k, err)
}
resp.Body.Close()
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
return fmt.Errorf("s3 delete %s: %s", k, resp.Status)
}
}
if next == "" {
return nil
}
continuation = next
}
}
// Presign mints a SigV4 query-signed URL for a single GET/PUT on the
// object — clients talk to S3 directly instead of proxying bytes through
// the backend. TTL is capped at one hour.
func (s *S3Store) Presign(ctx context.Context, bucketID, objectKey, method string, ttl time.Duration) (*PresignedURL, error) {
if method != http.MethodGet && method != http.MethodPut {
return nil, fmt.Errorf("s3 presign: unsupported method %q", method)
}
key, err := s.objectKey(bucketID, objectKey)
if err != nil {
return nil, err
}
if ttl <= 0 {
ttl = 15 * time.Minute
}
if ttl > time.Hour {
ttl = time.Hour
}
now := s.now().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
scope := fmt.Sprintf("%s/%s/s3/aws4_request", dateStamp, s.cfg.Region)
// Build the unsigned URL — same path/vhost logic as signedRequest, but
// on the client-facing endpoint when one is configured.
var base *url.URL
endpoint := strings.TrimSuffix(s.cfg.PublicEndpoint, "/")
if endpoint == "" {
endpoint = strings.TrimSuffix(s.cfg.Endpoint, "/")
}
if s.cfg.PathStyle {
u, err := url.Parse(endpoint + "/" + s.cfg.Bucket + "/" + escapeKey(key))
if err != nil {
return nil, fmt.Errorf("s3 presign url: %w", err)
}
base = u
} else {
u, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("s3 presign url: %w", err)
}
u.Host = s.cfg.Bucket + "." + u.Host
u.Path = "/" + escapeKey(key)
base = u
}
q := base.Query()
q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
q.Set("X-Amz-Credential", s.cfg.AccessKeyID+"/"+scope)
q.Set("X-Amz-Date", amzDate)
q.Set("X-Amz-Expires", fmt.Sprintf("%d", int64(ttl.Seconds())))
q.Set("X-Amz-SignedHeaders", "host")
canonicalURI := base.EscapedPath()
if canonicalURI == "" {
canonicalURI = "/"
}
canonicalRequest := strings.Join([]string{
method,
canonicalURI,
canonicalQuery(q),
"host:" + base.Host + "\n",
"host",
"UNSIGNED-PAYLOAD",
}, "\n")
stringToSign := strings.Join([]string{
"AWS4-HMAC-SHA256",
amzDate,
scope,
hexSHA256(canonicalRequest),
}, "\n")
kDate := hmacSHA256([]byte("AWS4"+s.cfg.SecretAccessKey), dateStamp)
kRegion := hmacSHA256(kDate, s.cfg.Region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
q.Set("X-Amz-Signature", hex.EncodeToString(hmacSHA256(kSigning, stringToSign)))
base.RawQuery = q.Encode()
return &PresignedURL{URL: base.String(), Method: method, ExpiresAt: now.Add(ttl)}, nil
}
type listResult struct {
Contents []struct{ Key string `xml:"Key"` } `xml:"Contents"`
NextContinuationToken string `xml:"NextContinuationToken"`
IsTruncated bool `xml:"IsTruncated"`
}
func (s *S3Store) listPage(prefix, continuation string) ([]string, string, error) {
query := url.Values{
"list-type": {"2"},
"prefix": {prefix},
"max-keys": {"1000"},
}
if continuation != "" {
query.Set("continuation-token", continuation)
}
req, err := s.signedRequestWithQuery(context.Background(), http.MethodGet, "", query)
if err != nil {
return nil, "", err
}
resp, err := s.client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("s3 list: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, "", fmt.Errorf("s3 list: %s: %s", resp.Status, readErrBody(resp))
}
var out listResult
if err := xml.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, "", fmt.Errorf("s3 list decode: %w", err)
}
keys := make([]string, len(out.Contents))
for i, c := range out.Contents {
keys[i] = c.Key
}
next := out.NextContinuationToken
if next == "" && out.IsTruncated && len(keys) > 0 {
next = keys[len(keys)-1] // S3 uses last key as marker when no token
}
return keys, next, nil
}
// --- SigV4 signing -------------------------------------------------------
func (s *S3Store) signedRequest(ctx context.Context, method, key string, body io.Reader) (*http.Request, error) {
return s.signed(ctx, method, key, nil, body)
}
func (s *S3Store) signedRequestWithQuery(ctx context.Context, method, key string, query url.Values) (*http.Request, error) {
return s.signed(ctx, method, key, query, nil)
}
func (s *S3Store) signed(ctx context.Context, method, key string, query url.Values, body io.Reader) (*http.Request, error) {
var reqURL string
endpoint := strings.TrimSuffix(s.cfg.Endpoint, "/")
if s.cfg.PathStyle {
reqURL = endpoint + "/" + s.cfg.Bucket
if key != "" {
reqURL += "/" + escapeKey(key)
}
} else {
u, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("s3 endpoint: %w", err)
}
u.Host = s.cfg.Bucket + "." + u.Host
u.Path = "/" + escapeKey(key)
reqURL = u.String()
}
if len(query) > 0 {
reqURL += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
if err != nil {
return nil, err
}
req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
s.sign(req)
return req, nil
}
// sign applies AWS Signature Version 4 to req in place.
func (s *S3Store) sign(req *http.Request) {
now := s.now().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
req.Header.Set("x-amz-date", amzDate)
// Canonical request: method, path, sorted query, headers, signed list,
// payload hash. Signed headers are host + every x-amz-* header present —
// required for x-amz-copy-source on CopyObject and future metadata
// headers.
signedHeaders := []string{"host"}
for h := range req.Header {
lh := strings.ToLower(h)
if strings.HasPrefix(lh, "x-amz-") {
signedHeaders = append(signedHeaders, lh)
}
}
sort.Strings(signedHeaders)
var canonicalHeaders strings.Builder
for _, h := range signedHeaders {
v := req.Host
if h != "host" {
v = strings.TrimSpace(req.Header.Get(h))
}
canonicalHeaders.WriteString(h + ":" + v + "\n")
}
canonicalURI := req.URL.EscapedPath()
if canonicalURI == "" {
canonicalURI = "/"
}
canonicalRequest := strings.Join([]string{
req.Method,
canonicalURI,
canonicalQuery(req.URL.Query()),
canonicalHeaders.String(),
strings.Join(signedHeaders, ";"),
"UNSIGNED-PAYLOAD",
}, "\n")
scope := fmt.Sprintf("%s/%s/s3/aws4_request", dateStamp, s.cfg.Region)
stringToSign := strings.Join([]string{
"AWS4-HMAC-SHA256",
amzDate,
scope,
hexSHA256(canonicalRequest),
}, "\n")
kDate := hmacSHA256([]byte("AWS4"+s.cfg.SecretAccessKey), dateStamp)
kRegion := hmacSHA256(kDate, s.cfg.Region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
req.Header.Set("Authorization", fmt.Sprintf(
"AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
s.cfg.AccessKeyID, scope, strings.Join(signedHeaders, ";"), signature))
}
// canonicalQuery re-encodes the query per SigV4: sorted keys, each name and
// value URI-encoded per RFC 3986 (url.Values.Encode's "+" for space is not
// acceptable here).
func canonicalQuery(q url.Values) string {
type pair struct{ k, v string }
pairs := make([]pair, 0, len(q))
for k, vs := range q {
for _, v := range vs {
pairs = append(pairs, pair{sigV4Escape(k), sigV4Escape(v)})
}
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].k == pairs[j].k {
return pairs[i].v < pairs[j].v
}
return pairs[i].k < pairs[j].k
})
var b strings.Builder
for i, p := range pairs {
if i > 0 {
b.WriteByte('&')
}
b.WriteString(p.k + "=" + p.v)
}
return b.String()
}
func sigV4Escape(s string) string {
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}
func hexSHA256(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
func hmacSHA256(key []byte, data string) []byte {
m := hmac.New(sha256.New, key)
m.Write([]byte(data))
return m.Sum(nil)
}
// EnsureBucket creates the configured bucket if it does not exist. Called
// once at startup when the s3 driver is selected; a pre-existing bucket is
// a no-op.
func (s *S3Store) EnsureBucket(ctx context.Context) error {
req, err := s.signedRequest(ctx, http.MethodPut, "", nil)
if err != nil {
return err
}
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("s3 create bucket: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusConflict {
return fmt.Errorf("s3 create bucket: %s: %s", resp.Status, readErrBody(resp))
}
return nil
}
func (s *S3Store) objectKey(bucketID, objectKey string) (string, error) {
clean, err := sanitizeObjectKey(objectKey)
if err != nil {
return "", err
}
return path.Join(s.cfg.Prefix, bucketID, clean), nil
}
func readErrBody(resp *http.Response) string {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return strings.TrimSpace(string(body))
}
+251
View File
@@ -0,0 +1,251 @@
package storage
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
)
// fakeS3 is an in-memory S3-compatible server covering the ops S3Store uses.
type fakeS3 struct {
mu sync.Mutex
objects map[string][]byte
bucket string
}
func newFakeS3(bucket string) *fakeS3 {
return &fakeS3{objects: map[string][]byte{}, bucket: bucket}
}
func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
key := strings.TrimPrefix(r.URL.Path, "/"+f.bucket+"/")
if r.Header.Get("Authorization") == "" || r.Header.Get("x-amz-date") == "" {
http.Error(w, "missing auth", http.StatusForbidden)
return
}
switch {
case r.Method == http.MethodPut && r.Header.Get("x-amz-copy-source") != "":
src := strings.TrimPrefix(r.Header.Get("x-amz-copy-source"), "/"+f.bucket+"/")
body, ok := f.objects[src]
if !ok {
http.Error(w, "source missing", http.StatusNotFound)
return
}
f.objects[key] = body
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPut:
body, _ := io.ReadAll(r.Body)
f.objects[key] = body
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodGet && r.URL.Query().Get("list-type") == "2":
prefix := r.URL.Query().Get("prefix")
var out strings.Builder
out.WriteString(`<?xml version="1.0"?><ListBucketResult>`)
for k := range f.objects {
if strings.HasPrefix(k, prefix) {
fmt.Fprintf(&out, "<Contents><Key>%s</Key></Contents>", k)
}
}
out.WriteString(`</ListBucketResult>`)
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(out.String()))
case r.Method == http.MethodGet:
body, ok := f.objects[key]
if !ok {
http.Error(w, "no such key", http.StatusNotFound)
return
}
_, _ = w.Write(body)
case r.Method == http.MethodDelete:
delete(f.objects, key)
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "unsupported", http.StatusBadRequest)
}
}
func newTestS3(t *testing.T) (*S3Store, *fakeS3) {
t.Helper()
fake := newFakeS3("primora-objects")
srv := httptest.NewServer(fake)
t.Cleanup(srv.Close)
store, err := NewS3Store(S3Config{
Endpoint: srv.URL,
Region: "us-east-1",
Bucket: "primora-objects",
AccessKeyID: "test-key",
SecretAccessKey: "test-secret",
PathStyle: true,
})
if err != nil {
t.Fatalf("NewS3Store: %v", err)
}
return store, fake
}
func TestS3StorePutGetDeleteRoundTrip(t *testing.T) {
store, fake := newTestS3(t)
ctx := context.Background()
res, err := store.Put(ctx, "bucket-a", "docs/hello.txt", strings.NewReader("hello s3"))
if err != nil {
t.Fatalf("Put: %v", err)
}
if res.SizeBytes != 8 {
t.Fatalf("size = %d, want 8", res.SizeBytes)
}
if res.Path != "s3://primora-objects/bucket-a/docs/hello.txt" {
t.Fatalf("path = %q", res.Path)
}
// sha256("hello s3")
if res.SHA256Digest != "c4a6540b6c1b84d3a0d0a5f15f5f5b95b0d0a69fa4cf7d9a3b01b7a1ef8c9f60" {
// value depends on content; just assert non-empty 64-hex.
if len(res.SHA256Digest) != 64 {
t.Fatalf("digest = %q", res.SHA256Digest)
}
}
rc, path, err := store.Open("bucket-a", "docs/hello.txt")
if err != nil {
t.Fatalf("Open: %v", err)
}
body, _ := io.ReadAll(rc)
rc.Close()
if string(body) != "hello s3" {
t.Fatalf("got %q", body)
}
if !strings.HasPrefix(path, "s3://") {
t.Fatalf("path = %q", path)
}
if err := store.Delete("bucket-a", "docs/hello.txt"); err != nil {
t.Fatalf("Delete: %v", err)
}
if len(fake.objects) != 0 {
t.Fatalf("objects remain: %v", fake.objects)
}
}
func TestS3StoreMoveAndDeleteBucket(t *testing.T) {
store, fake := newTestS3(t)
ctx := context.Background()
if _, err := store.Put(ctx, "bucket-a", "a.txt", strings.NewReader("A")); err != nil {
t.Fatal(err)
}
if _, err := store.Put(ctx, "bucket-a", "b.txt", strings.NewReader("B")); err != nil {
t.Fatal(err)
}
newPath, err := store.MoveBetweenBuckets("bucket-a", "bucket-b", "a.txt", "moved/a.txt")
if err != nil {
t.Fatalf("Move: %v", err)
}
if !strings.Contains(newPath, "bucket-b/moved/a.txt") {
t.Fatalf("path = %q", newPath)
}
if _, ok := fake.objects["bucket-a/a.txt"]; ok {
t.Fatal("source not deleted after move")
}
if got := string(fake.objects["bucket-b/moved/a.txt"]); got != "A" {
t.Fatalf("moved content = %q", got)
}
if err := store.DeleteBucket("bucket-a"); err != nil {
t.Fatalf("DeleteBucket: %v", err)
}
for k := range fake.objects {
if strings.HasPrefix(k, "bucket-a/") {
t.Fatalf("key %s survived DeleteBucket", k)
}
}
// bucket-b untouched
if _, ok := fake.objects["bucket-b/moved/a.txt"]; !ok {
t.Fatal("DeleteBucket removed objects from another bucket")
}
}
func TestS3StoreInvalidKeyRejected(t *testing.T) {
store, _ := newTestS3(t)
if _, err := store.Put(context.Background(), "b", "../escape", strings.NewReader("x")); err == nil {
t.Fatal("expected invalid key error")
}
}
// TestSigV4Signature pins the signing algorithm against a fixed clock: the
// Authorization header must be deterministic for identical inputs.
func TestSigV4SignatureDeterministic(t *testing.T) {
store, _ := newTestS3(t)
store.now = func() time.Time {
return time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
}
req, err := store.signedRequest(context.Background(), http.MethodGet, "bucket-a/x.txt", nil)
if err != nil {
t.Fatal(err)
}
auth := req.Header.Get("Authorization")
if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 Credential=test-key/20260924/us-east-1/s3/aws4_request") {
t.Fatalf("unexpected auth header: %s", auth)
}
if !strings.Contains(auth, "SignedHeaders=host;x-amz-content-sha256;x-amz-date") {
t.Fatalf("signed headers: %s", auth)
}
if req.Header.Get("x-amz-date") != "20260924T120000Z" {
t.Fatalf("amz date: %s", req.Header.Get("x-amz-date"))
}
}
func TestCanonicalQueryRFC3986(t *testing.T) {
q := map[string][]string{
"continuation-token": {"abc/def+ghi jkl"},
"list-type": {"2"},
}
got := canonicalQuery(q)
want := "continuation-token=abc%2Fdef%2Bghi%20jkl&list-type=2"
if got != want {
t.Fatalf("canonicalQuery = %q, want %q", got, want)
}
}
func TestS3StorePresign(t *testing.T) {
store, _ := newTestS3(t)
ctx := context.Background()
dl, err := store.Presign(ctx, "bucket-a", "docs/x.txt", http.MethodGet, 10*time.Minute)
if err != nil {
t.Fatalf("Presign: %v", err)
}
if dl.Method != http.MethodGet || dl.ExpiresAt.IsZero() {
t.Fatalf("bad presign: %+v", dl)
}
u, err := url.Parse(dl.URL)
if err != nil {
t.Fatalf("url parse: %v", err)
}
q := u.Query()
for _, k := range []string{"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-Expires", "X-Amz-SignedHeaders", "X-Amz-Signature"} {
if q.Get(k) == "" {
t.Fatalf("missing %s in %s", k, dl.URL)
}
}
if q.Get("X-Amz-Expires") != "600" || q.Get("X-Amz-SignedHeaders") != "host" {
t.Fatalf("bad query: %s", dl.URL)
}
if _, err := store.Presign(ctx, "b", "k", http.MethodDelete, 0); err == nil {
t.Fatal("expected method rejection")
}
// TTL cap.
capped, _ := store.Presign(ctx, "b", "k.txt", http.MethodPut, 48*time.Hour)
if cu, _ := url.Parse(capped.URL); cu.Query().Get("X-Amz-Expires") != "3600" {
t.Fatalf("ttl not capped: %s", capped.URL)
}
}
+31
View File
@@ -0,0 +1,31 @@
package storage
import (
"context"
"io"
"time"
)
// Store is the object-storage contract the platform service depends on.
// LocalStore keeps objects on disk; S3Store targets S3-compatible APIs.
type Store interface {
Put(ctx context.Context, bucketID, objectKey string, reader io.Reader) (PutResult, error)
Open(bucketID, objectKey string) (io.ReadCloser, string, error)
Delete(bucketID, objectKey string) error
MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error)
DeleteBucket(bucketID string) error
}
// PresignedURL is a short-lived direct-to-storage URL for one operation.
type PresignedURL struct {
URL string `json:"url"`
Method string `json:"method"`
ExpiresAt time.Time `json:"expires_at"`
}
// Presigner is an optional capability: stores backed by a real object API
// (S3) can mint URLs clients call directly. The local driver can't — its
// "URL" would have to be served by this backend anyway.
type Presigner interface {
Presign(ctx context.Context, bucketID, objectKey, method string, ttl time.Duration) (*PresignedURL, error)
}
File diff suppressed because it is too large. Load diff
+139 -2
View File
@@ -1,6 +1,7 @@
# @primora/cli # @primora/cli
`primora` - command-line client for a Primora deployment. `primora` - command-line client for a Primora deployment, plus a local
encrypted secrets vault and compose-stack operator commands.
## Build ## Build
@@ -20,4 +21,140 @@ project interactively.
## Commands ## Commands
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`, `orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`,
`audit list --follow`. Every command accepts `--json`. `jobs`, `events:send`, `audit list --follow`. Every command accepts `--json`.
```sh
primora events:send custom.deploy.done --data '{"sha":"abc123"}'
```
`events:send` publishes a `custom.*` event — realtime subscribers, matching
webhooks, and `event_pattern` functions all fire.
```sh
primora documents list --collection users \
--filter "status.eq.active,age.gt.18" --order "age.desc"
```
`--filter` takes comma-separated `field.op.value` terms — ops `eq neq gt gte lt
lte like in is`; dotted paths index into document data (`meta.city.eq.Prague`),
`id`/`created_at`/`updated_at` are also filterable. `--order` is a
`field.asc|field.desc` list.
## Vault
A local encrypted vault for secrets the CLI holds - API keys, deployment `.env`
values, anything you would otherwise leave in a dotfile or shell history.
```bash
primora vault init # create ~/.config/primora/vault.bin
primora secrets set STRIPE_KEY # value prompted, or --value / stdin
primora secrets set OPENAI --value sk-… --url https://platform.openai.com --notes "prod"
primora secrets import .env # import KEY=value pairs
primora secrets list # names + urls + timestamps, never values
primora secrets get STRIPE_KEY # prints the value (scriptable)
primora secrets rm STRIPE_KEY --yes
```
Format: Argon2id (64 MiB / 3 / 4) → XChaCha20-Poly1305 over a single JSON
payload; the parameter header is the AEAD's AAD, atomic tmp+rename writes.
Clean-room implementation - compatible with nothing but itself.
Password source order: live session → `PRIMORA_VAULT_PASSWORD` →
`--password-file` → hidden prompt. `PRIMORA_VAULT` overrides the file
location.
## Project vault (server)
`--remote` targets the project's server-side vault instead of the local file —
AES-256-GCM at rest in Postgres, shared with the team and the web dashboard's
Vault page. No vault password; it uses your `primora login` session or
`PRIMORA_API_KEY`.
```bash
primora secrets set STRIPE_KEY --value sk-… --remote
primora secrets list --remote # metadata only — names, urls, notes
primora secrets get STRIPE_KEY --remote # explicit reveal, audit-logged
primora secrets rm STRIPE_KEY --remote --yes
primora secrets import .env --remote --overwrite
```
Job payloads reference secrets as `secret://NAME`; the scheduler resolves them
at delivery, so payloads on disk never contain plaintext:
```json
{"auth": "secret://STRIPE_KEY"}
```
## Sessions
`primora vault unlock --ttl 900` proves the password once, then writes a
TTL'd session key (`vault.session`, 0600) so agents and scripts use the
vault without ever seeing the password. `primora vault lock` revokes
immediately; expiry does it on its own. The session file is same-user
readable for its lifetime — that is the trade, bounded by the TTL.
## Agent broker
`primora agent` wraps one process: the child gets dummy tokens and a
loopback base URL; the broker attaches the real secret on the wire.
```bash
primora agent --anthropic CLAUDE_KEY --openai OAI_KEY --ttl 900 -- claude
primora agent --upstream stripe=https://api.stripe.com -- npm test
```
Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`,
`github`, `npm`. Each flag takes a vault secret name; `--upstream
name=https://…` adds any Bearer API (vault key `<NAME>_KEY`).
The child environment contains `*_API_KEY=primora-brokered` and a
`*_BASE_URL` pointing at the broker — so the tool must honor a base-URL
override. `gh` hardcodes api.github.com and cannot be brokered this way.
This is a base-URL broker, not a TLS MITM; no CA trust needed.
## Inject
Run one process with vault secrets in its environment - nothing on disk,
nothing in argv, parent shell unchanged:
```bash
primora inject -- npm run dev # all vault secrets
primora inject --env-file .env -- npm start # resolve primora:// refs
```
An env-file can mix literals and refs:
```dotenv
DATABASE_URL=postgres://localhost/app
STRIPE_KEY=primora://STRIPE_KEY
```
## Stack
Operator surface for a compose deployment (`--dir`, defaults to cwd):
```bash
primora stack up # docker compose up -d
primora stack up --vault # materialize .env from the vault, remove after
primora stack status | logs [svc] | down | pull
primora stack backup # pg_dump → ./backups (storage: scripts/backup.sh)
```
`stack up --vault` refuses to overwrite an existing `.env` without `--force`
(the original is restored after the run). For a full backup including the
storage volume, `scripts/backup.sh` remains the tool.
## For AI agents
Two safe paths, no secret ever in the agent's context:
- **Wrapped** — launch the agent under `primora agent … -- claude`. It holds
dummy tokens; the broker attaches real ones on the wire.
- **MCP** — `primora_vault_status`, `primora_vault_list` (metadata only),
`primora_vault_exec` (runs `primora` commands with a secret injected into
the process env; vault/secrets/inject/agent/login and `keys:create` are
denied, so output can never carry a credential). Requires an unlocked
vault session on the host.
- **MCP project vault** — `primora_secrets_list` (metadata only) and
`primora_secrets_set` (write-only). No reveal, no delete: agents reference
`secret://NAME` in job payloads without ever holding the value.
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@primora/cli", "name": "@primora/cli",
"version": "0.5.0", "version": "0.6.0",
"description": "Primora command-line client", "description": "Primora command-line client",
"type": "module", "type": "module",
"bin": { "bin": {
@@ -19,6 +19,8 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.8.1", "@clack/prompts": "^1.8.1",
"@noble/ciphers": "^2.4.0",
"@noble/hashes": "^2.4.0",
"cac": "^7.0.0", "cac": "^7.0.0",
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
}, },
+242 -5
View File
@@ -2,18 +2,44 @@ import { cac } from "cac";
import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js"; import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
import { cmdAuditList } from "./commands/audit.js"; import { cmdAuditList } from "./commands/audit.js";
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js"; import { cmdDocumentsList } from "./commands/documents.js";
import { cmdEventsSend } from "./commands/events.js";
import { cmdBucketsCreate, cmdBucketsList, cmdBucketsRemove } from "./commands/buckets.js";
import { cmdContext, cmdUse } from "./commands/context.js"; import { cmdContext, cmdUse } from "./commands/context.js";
import { cmdInject } from "./commands/inject.js";
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js"; import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js"; import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js";
import { import {
cmdObjectsDownload, cmdObjectsDownload,
cmdObjectsList, cmdObjectsList,
cmdObjectsRemove, cmdObjectsRemove,
cmdObjectsPresign,
cmdObjectsUpload, cmdObjectsUpload,
} from "./commands/objects.js"; } from "./commands/objects.js";
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js"; import { cmdOrgsCreate, cmdOrgsList, cmdOrgsRemove } from "./commands/orgs.js";
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js"; import { cmdProjectsCreate, cmdProjectsList, cmdProjectsRemove } from "./commands/projects.js";
import {
cmdSecretsGet,
cmdSecretsImport,
cmdSecretsList,
cmdSecretsRemove,
cmdSecretsSet,
} from "./commands/secrets.js";
import { cmdAgent, PRESET_NAMES } from "./commands/agent.js";
import {
cmdStackBackup,
cmdStackDown,
cmdStackLogs,
cmdStackPull,
cmdStackStatus,
cmdStackUp,
} from "./commands/stack.js";
import {
cmdVaultInit,
cmdVaultLock,
cmdVaultStatus,
cmdVaultUnlock,
} from "./commands/vault.js";
import { fail } from "./out.js"; import { fail } from "./out.js";
const cli = cac("primora"); const cli = cac("primora");
@@ -26,6 +52,7 @@ cli
.option("--email <email>", "Account email") .option("--email <email>", "Account email")
.option("--password <password>", "Account password") .option("--password <password>", "Account password")
.option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session") .option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session")
.option("--json", "Machine-readable output")
.action(cmdLogin); .action(cmdLogin);
cli.command("logout", "Remove stored credentials").action(cmdLogout); cli.command("logout", "Remove stored credentials").action(cmdLogout);
@@ -44,6 +71,10 @@ cli
.command("orgs:create <name>", "Create an organization") .command("orgs:create <name>", "Create an organization")
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)") .option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.action(cmdOrgsCreate); .action(cmdOrgsCreate);
cli
.command("orgs:rm <org>", "Delete an organization (id, slug, or name)")
.option("--yes", "Skip the confirmation prompt")
.action(cmdOrgsRemove);
cli cli
.command("projects:list", "List projects in the current org") .command("projects:list", "List projects in the current org")
@@ -56,6 +87,10 @@ cli
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)") .option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.option("--description <text>", "Description") .option("--description <text>", "Description")
.action(cmdProjectsCreate); .action(cmdProjectsCreate);
cli
.command("projects:rm <project>", "Delete a project (id, slug, or name)")
.option("--yes", "Skip the confirmation prompt")
.action(cmdProjectsRemove);
cli cli
.command("buckets:list", "List buckets in the current project") .command("buckets:list", "List buckets in the current project")
@@ -68,6 +103,11 @@ cli
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)") .option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.option("--public", "Public visibility (default private)") .option("--public", "Public visibility (default private)")
.action(cmdBucketsCreate); .action(cmdBucketsCreate);
cli
.command("buckets:rm <bucket>", "Delete a bucket and its objects (id or slug)")
.option("--project <id>", "Project override")
.option("--yes", "Skip the confirmation prompt")
.action(cmdBucketsRemove);
cli cli
.command("objects:list <bucket>", "List objects in a bucket (id or slug)") .command("objects:list <bucket>", "List objects in a bucket (id or slug)")
@@ -90,6 +130,12 @@ cli
.command("objects:rm <bucket> <key>", "Delete an object") .command("objects:rm <bucket> <key>", "Delete an object")
.option("--project <id>", "Project override") .option("--project <id>", "Project override")
.action(cmdObjectsRemove); .action(cmdObjectsRemove);
cli
.command("objects:presign <bucket> <key>", "Mint a presigned URL (s3 driver)")
.option("--project <id>", "Project override")
.option("--upload", "PUT URL for direct upload instead of GET download")
.option("--ttl <seconds>", "Lifetime 60-3600 (default 900)")
.action(cmdObjectsPresign);
cli cli
.command("keys:list", "List API keys for the current project") .command("keys:list", "List API keys for the current project")
@@ -133,6 +179,22 @@ cli
.option("--yes", "Skip the confirmation prompt") .option("--yes", "Skip the confirmation prompt")
.action(cmdJobsRemove); .action(cmdJobsRemove);
cli
.command("documents:list", "List documents in a collection (PostgREST-style --filter/--order)")
.option("--collection <slug|id>", "Collection slug or id (required)")
.option("--project <id>", "Project override")
.option("--filter <expr>", "field.op.value terms, comma-separated (eq neq gt gte lt lte like in is)")
.option("--order <expr>", "field.asc|field.desc list")
.option("--limit <n>", "Page size (default 50)")
.option("--offset <n>", "Page offset")
.action(cmdDocumentsList);
cli
.command("events:send <type>", "Publish a custom.* event to realtime subscribers, webhooks, and functions")
.option("--project <id>", "Project override")
.option("--data <json>", "JSON object payload")
.action(cmdEventsSend);
cli cli
.command("audit:list", "List audit events for the current project") .command("audit:list", "List audit events for the current project")
.option("--project <id>", "Project override") .option("--project <id>", "Project override")
@@ -144,21 +206,196 @@ cli
.option("--interval <s>", "Poll interval in seconds with --follow (default 2)") .option("--interval <s>", "Poll interval in seconds with --follow (default 2)")
.action(cmdAuditList); .action(cmdAuditList);
// Local vault — secrets on this machine, not on the server. Password source:
// live session → PRIMORA_VAULT_PASSWORD → --password-file → hidden prompt.
cli
.command("vault:init", "Create the local secrets vault")
.option("--password-file <path>", "Read the vault password from a file")
.action(cmdVaultInit);
cli
.command("vault:status", "Show vault path, KDF parameters and lock state")
.action(cmdVaultStatus);
cli
.command("vault:unlock", "Open a TTL'd session — agents use the vault without the password")
.option("--ttl <s>", "Session lifetime in seconds (default 900, cap 86400)")
.option("--password-file <path>", "Read the vault password from a file")
.action(cmdVaultUnlock);
cli
.command("vault:lock", "Revoke the session immediately")
.action(cmdVaultLock);
cli
.command("secrets:set <name>", "Store a secret in the vault")
.option("--value <value>", "Secret value (otherwise prompted or read from stdin)")
.option("--url <url>", "Associated URL (dashboard, docs, endpoint)")
.option("--notes <text>", "Free-text notes")
.option("--remote", "Target the project vault on the server instead of the local vault")
.option("--project <id>", "Project override (with --remote)")
.option("--password-file <path>", "Read the vault password from a file")
.action(cmdSecretsSet);
cli
.command("secrets:get [name]", "Print a secret's value (interactive picker if omitted)")
.option("--remote", "Reveal from the project vault on the server (audit-logged)")
.option("--project <id>", "Project override (with --remote)")
.option("--password-file <path>", "Read the vault password from a file")
.action(cmdSecretsGet);
cli
.command("secrets:list", "List secret names (never values)")
.option("--remote", "List project vault secrets on the server (metadata only)")
.option("--project <id>", "Project override (with --remote)")
.option("--password-file <path>", "Read the vault password from a file")
.action(cmdSecretsList);
cli
.command("secrets:rm [name]", "Delete a secret (interactive picker if omitted)")
.option("--remote", "Delete from the project vault on the server")
.option("--project <id>", "Project override (with --remote)")
.option("--password-file <path>", "Read the vault password from a file")
.option("--yes", "Skip the confirmation prompt")
.action(cmdSecretsRemove);
cli
.command("secrets:import <file>", "Import a .env file into the vault")
.option("--remote", "Import into the project vault on the server")
.option("--project <id>", "Project override (with --remote)")
.option("--password-file <path>", "Read the vault password from a file")
.option("--overwrite", "Replace existing secrets")
.action(cmdSecretsImport);
cli
.command("inject", "Run a command with vault secrets in its environment")
.option("--all", "Inject every vault secret (default when no --env-file)")
.option("--env-file <path>", "env template; primora://NAME values resolve from the vault")
.option("--password-file <path>", "Read the vault password from a file")
.action(cmdInject);
// Agent broker — the child gets dummy tokens; the loopback proxy attaches the
// real secret per grant. One flag per preset takes a vault secret name.
const agentCmd = cli
.command("agent", "Wrap a command with the credential broker (dummy env, real secret on the wire)")
.option("--ttl <s>", "Grant lifetime in seconds (default 900, cap 3600)")
.option("--upstream <name=url>", "Custom Bearer upstream, repeatable (vault key: <NAME>_KEY)")
.option("--password-file <path>", "Read the vault password from a file");
for (const preset of PRESET_NAMES) {
agentCmd.option(`--${preset} <name>`, `Grant ${preset} API access from vault secret <name>`);
}
agentCmd.action(cmdAgent);
// Stack — operator surface for the compose deployment in --dir (default cwd).
cli
.command("stack:up", "docker compose up -d")
.option("--dir <path>", "Deployment directory (default: cwd)")
.option("--vault", "Materialize .env from the vault for this run only")
.option("--force", "Allow --vault to temporarily swap an existing .env")
.option("--password-file <path>", "Read the vault password from a file")
.action(cmdStackUp);
cli
.command("stack:down", "docker compose down")
.option("--dir <path>", "Deployment directory (default: cwd)")
.action(cmdStackDown);
cli
.command("stack:status", "docker compose ps")
.option("--dir <path>", "Deployment directory (default: cwd)")
.action(cmdStackStatus);
cli
.command("stack:logs [service]", "docker compose logs")
.option("--dir <path>", "Deployment directory (default: cwd)")
.option("--follow", "Follow log output")
.option("--tail <n>", "Lines per service (default 200)")
.action(cmdStackLogs);
cli
.command("stack:pull", "Pull newer images")
.option("--dir <path>", "Deployment directory (default: cwd)")
.action(cmdStackPull);
cli
.command("stack:backup", "pg_dump the platform database into ./backups")
.option("--dir <path>", "Deployment directory (default: cwd)")
.option("--out <path>", "Backup directory (default: <dir>/backups)")
.action(cmdStackBackup);
cli.help(); cli.help();
cli.version("0.4.0"); cli.version("0.6.0");
cli.example("primora login --url https://primora.example.com");
cli.example("primora vault init");
cli.example("primora secrets import .env");
cli.example("primora secrets list --remote");
cli.example("primora inject --env-file .env -- npm run dev");
cli.example("primora agent --anthropic CLAUDE_KEY -- claude");
// cac only matches the first positional token, so "orgs list" cannot be a // cac only matches the first positional token, so "orgs list" cannot be a
// command name. Fold "group verb" pairs into "group:verb" before parsing — // command name. Fold "group verb" pairs into "group:verb" before parsing —
// `primora orgs list` and `primora orgs:list` both work. // `primora orgs list` and `primora orgs:list` both work.
const GROUPS = new Set(["orgs", "projects", "buckets", "objects", "keys", "jobs", "audit"]); const GROUPS = new Set([
"orgs",
"projects",
"buckets",
"objects",
"documents",
"keys",
"jobs",
"audit",
"vault",
"secrets",
"stack",
]);
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) { if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
argv.splice(0, 2, `${argv[0]}:${argv[1]}`); argv.splice(0, 2, `${argv[0]}:${argv[1]}`);
} else if (argv[0] === "vault" && !argv[1]) {
argv[0] = "vault:status";
} else if (argv[0] === "secrets" && !argv[1]) {
argv[0] = "secrets:list";
} }
// mri coerces numeric-looking option values ("007" → 7, "" → 0), which
// corrupts secrets, keys, and passwords. Re-extract raw strings for every
// non-boolean flag and patch parsed options before the action runs.
function restoreRawOptionStrings(): void {
const cmd = (cli as unknown as { matchedCommand?: { options: { isBoolean: boolean; name: string; names: string[] }[] } })
.matchedCommand;
if (!cmd) return;
const state = cli as unknown as { options: Record<string, unknown>; globalCommand: { options: { isBoolean: boolean; name: string; names: string[] }[] } };
const specs = [...state.globalCommand.options, ...cmd.options].filter((o) => !o.isBoolean);
const raw = process.argv.slice(2);
const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
for (const spec of specs) {
if (typeof state.options[spec.name] !== "number") continue;
let value: string | undefined;
for (let i = 0; i < raw.length; i++) {
for (const n of spec.names) {
const flags = n.length === 1 ? [`-${n}`] : [`--${n}`, `--${kebab(n)}`];
for (const f of flags) {
if (raw[i] === f) value = raw[i + 1];
else if (raw[i].startsWith(f + "=")) value = raw[i].slice(f.length + 1);
}
}
}
if (value !== undefined) state.options[spec.name] = value;
}
}
// Unknown command or a bare group — cac only fires this on non-empty args.
cli.addEventListener("command:*", () => {
const state = cli as unknown as { args: string[]; commands: { name: string }[] };
const arg = String(state.args[0] ?? "");
const group = arg.split(":")[0];
const verbs = GROUPS.has(group)
? state.commands.map((c) => c.name).filter((n) => n.startsWith(group + ":")).map((n) => n.slice(group.length + 1))
: [];
process.stderr.write(`error: unknown command "${arg.replace(":", " ")}"\n`);
if (verbs.length) process.stderr.write(` ${group} verbs: ${verbs.join(", ")}\n`);
process.stderr.write(" run `primora --help` for usage\n");
process.exit(1);
});
async function main() { async function main() {
try { try {
cli.parse([process.argv[0], process.argv[1], ...argv], { run: false }); cli.parse([process.argv[0], process.argv[1], ...argv], { run: false });
restoreRawOptionStrings();
const state = cli as unknown as { matchedCommand?: unknown; options: Record<string, unknown> };
if (!state.matchedCommand && !state.options.help && !state.options.version) {
cli.outputHelp();
if (argv.length) process.exit(1);
return;
}
await cli.runMatchedCommand(); await cli.runMatchedCommand();
} catch (error) { } catch (error) {
fail(error); fail(error);
+236
View File
@@ -0,0 +1,236 @@
import { spawn } from "node:child_process";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { CliError } from "../http.js";
import { dim } from "../out.js";
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
/**
* `primora agent` — loopback credential broker. Wraps one child process with
* dummy tokens; the proxy attaches the real secret on the wire per grant.
* The child (an AI agent, a script) never holds a real key.
*
* Transport note: this is a base-URL broker, not a TLS MITM — tools must honor
* their *_BASE_URL / registry / api-url override (OpenAI, Anthropic, Groq,
* DeepSeek, xAI, OpenRouter, npm do; `gh` hardcodes api.github.com). That is
* the documented ceiling — a CA-trusted MITM is a much larger build.
* ponytail: no standalone daemon (`agent start`) — wrap mode only.
*/
interface Grant {
upstream: string; // e.g. https://api.anthropic.com
header: (secret: string) => [string, string];
}
interface Preset extends Grant {
env: (base: string) => Record<string, string>; // child env; base = http://127.0.0.1:port/<name>
}
const DUMMY = "primora-brokered";
const bearer = (s: string): [string, string] => ["authorization", `Bearer ${s}`];
const PRESETS: Record<string, Preset> = {
anthropic: {
upstream: "https://api.anthropic.com",
header: (s) => ["x-api-key", s],
env: (b) => ({ ANTHROPIC_BASE_URL: b, ANTHROPIC_API_KEY: DUMMY }),
},
openai: {
upstream: "https://api.openai.com",
header: bearer,
env: (b) => ({ OPENAI_BASE_URL: `${b}/v1`, OPENAI_API_KEY: DUMMY }),
},
openrouter: {
upstream: "https://openrouter.ai",
header: bearer,
env: (b) => ({ OPENROUTER_BASE_URL: `${b}/api`, OPENROUTER_API_KEY: DUMMY }),
},
groq: {
upstream: "https://api.groq.com",
header: bearer,
env: (b) => ({ GROQ_BASE_URL: `${b}/openai`, GROQ_API_KEY: DUMMY }),
},
deepseek: {
upstream: "https://api.deepseek.com",
header: bearer,
env: (b) => ({ DEEPSEEK_BASE_URL: b, DEEPSEEK_API_KEY: DUMMY }),
},
xai: {
upstream: "https://api.x.ai",
header: bearer,
env: (b) => ({ XAI_BASE_URL: `${b}/v1`, XAI_API_KEY: DUMMY }),
},
github: {
upstream: "https://api.github.com",
header: bearer,
env: (b) => ({ GITHUB_API_URL: b, GH_TOKEN: DUMMY, GITHUB_TOKEN: DUMMY }),
},
npm: {
upstream: "https://registry.npmjs.org",
header: bearer,
env: (b) => ({ NPM_CONFIG_REGISTRY: b, NPM_TOKEN: DUMMY }),
},
};
export const PRESET_NAMES = Object.keys(PRESETS);
export interface AgentOptions extends VaultPasswordOptions {
ttl?: string;
upstream?: string | string[];
[preset: string]: unknown; // cac preset flags land here
}
const HOP_BY_HOP = new Set([
"host",
"connection",
"content-length",
"transfer-encoding",
"keep-alive",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"upgrade",
]);
async function proxy(
req: IncomingMessage,
res: ServerResponse,
grants: Map<string, { grant: Grant; secret: string }>,
): Promise<void> {
const match = /^\/([a-z0-9_-]+)(\/.*)?$/i.exec(req.url ?? "");
const entry = match ? grants.get(match[1].toLowerCase()) : undefined;
if (!match || !entry) {
res.writeHead(404, { "content-type": "text/plain" }).end("primora agent: no grant for this route");
return;
}
const suffix = match[2] ?? "/";
const headers: Record<string, string> = {};
for (const [k, v] of Object.entries(req.headers)) {
const lk = k.toLowerCase();
if (HOP_BY_HOP.has(lk) || Array.isArray(v) || v === undefined) continue;
headers[lk] = v;
}
const [hk, hv] = entry.grant.header(entry.secret);
headers[hk] = hv; // real secret attached here — the child sent a dummy
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const body = chunks.length ? Buffer.concat(chunks) : undefined;
try {
const up = await fetch(entry.grant.upstream + suffix, {
method: req.method,
headers,
body,
redirect: "manual",
});
const out: Record<string, string> = {};
up.headers.forEach((v, k) => {
if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
});
res.writeHead(up.status, out);
if (up.body) Readable.fromWeb(up.body as never).pipe(res);
else res.end();
} catch (e) {
res.writeHead(502).end(`primora agent: upstream error — ${(e as Error).message}`);
}
}
export async function cmdAgent(options: AgentOptions): Promise<void> {
const sep = process.argv.indexOf("--");
const child = sep >= 0 ? process.argv.slice(sep + 1) : [];
if (!child.length) {
throw new CliError(
"No command given.",
"Usage: primora agent --anthropic VAULT_KEY [--ttl 900] -- <cmd>",
);
}
// Collect grants: --<preset> <vault-name> plus custom --upstream name=url.
const wanted: { name: string; grant: Grant; env?: Preset["env"]; secretName: string }[] = [];
for (const name of PRESET_NAMES) {
const secretName = options[name];
if (typeof secretName === "string" && secretName) {
wanted.push({ name, grant: PRESETS[name], env: PRESETS[name].env, secretName });
}
}
const customs = ([] as string[]).concat(options.upstream ?? []);
for (const spec of customs) {
const eq = spec.indexOf("=");
const url = spec.slice(eq + 1);
// HTTPS for real upstreams; HTTP only for loopback dev servers.
const urlOk =
/^https:\/\//.test(url) || /^http:\/\/(127\.|localhost|\[::1\])/.test(url);
if (eq < 1 || !urlOk) {
throw new CliError(
`Bad --upstream "${spec}".`,
"Format: name=https://api.host (http allowed for 127.0.0.1/localhost only) — Authorization: Bearer is attached.",
);
}
const name = spec.slice(0, eq).toLowerCase();
const upstream = spec.slice(eq + 1).replace(/\/+$/, "");
wanted.push({
name,
grant: { upstream, header: bearer },
env: (b) => ({
[`${name.toUpperCase().replace(/-/g, "_")}_BASE_URL`]: b,
[`${name.toUpperCase().replace(/-/g, "_")}_TOKEN`]: DUMMY,
}),
secretName: name.toUpperCase().replace(/-/g, "_") + "_KEY",
});
}
if (!wanted.length) {
throw new CliError(
"No grants.",
`Pass at least one: ${PRESET_NAMES.map((n) => `--${n} <vault-key>`).join(" ")} or --upstream name=https://…`,
);
}
const vault = await unlockVault(options);
const grants = new Map<string, { grant: Grant; secret: string }>();
for (const w of wanted) {
const entry = vault.data.secrets[w.secretName];
if (!entry) {
throw new CliError(
`Secret "${w.secretName}" not in vault for grant "${w.name}".`,
`Run \`primora secrets set ${w.secretName}\` first.`,
);
}
grants.set(w.name, { grant: w.grant, secret: entry.value });
}
const server = createServer((req, res) => void proxy(req, res, grants));
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const port = (server.address() as { port: number }).port;
const base = `http://127.0.0.1:${port}`;
const ttl = Math.min(Math.max(60, Number(options.ttl ?? 900)), 3600);
const timer = setTimeout(() => {
server.close();
process.stderr.write("primora agent: grant TTL expired — broker stopped\n");
process.exit(0);
}, ttl * 1000);
const env: NodeJS.ProcessEnv = { ...process.env };
for (const w of wanted) Object.assign(env, w.env?.(`${base}/${w.name}`));
dim(`broker on ${base} — grants: ${wanted.map((w) => w.name).join(", ")} (ttl ${ttl}s)`);
// Async spawn — spawnSync would block the event loop and starve the proxy.
const code = await new Promise<number>((resolve, reject) => {
const proc = spawn(child[0], child.slice(1), { env, stdio: "inherit" });
proc.on("error", (e) => {
server.close();
reject(new CliError(`Failed to start ${child[0]}: ${e.message}`));
});
proc.on("exit", (c) => resolve(c ?? 1));
});
clearTimeout(timer);
server.close();
process.exit(code);
}
+37 -2
View File
@@ -10,6 +10,7 @@ interface LoginOptions {
email?: string; email?: string;
password?: string; password?: string;
apiKey?: string; apiKey?: string;
json?: boolean;
} }
function cancel(): never { function cancel(): never {
@@ -39,9 +40,22 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…"); throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…");
} }
cfg.auth = { type: "apiKey", key: options.apiKey }; cfg.auth = { type: "apiKey", key: options.apiKey };
// Validate the key against the instance before saving — a stored-but-
// dead key is a worse failure than a rejected login.
configureClient(cfg);
const ctx = await PlatformService.getActorContext();
if (ctx.organization) cfg.organizationId = ctx.organization.id;
if (ctx.project) cfg.projectId = ctx.project.id;
saveConfig(cfg); saveConfig(cfg);
success(`Saved API key credentials to ${configFilePath()}`); if (isJson(options)) {
dim("API keys are project-scoped — set context with `primora use --org <id> --project <id>`."); printJson(ctx);
return;
}
success(`API key verified — ${ctx.key_prefix}… (scopes: ${ctx.scopes?.join(", ") || "none"})`);
if (ctx.organization && ctx.project) {
success(`Context → ${ctx.organization.slug} / ${ctx.project.slug}`);
}
dim(`Config written to ${configFilePath()}`);
return; return;
} }
@@ -94,6 +108,10 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
configureClient(cfg); configureClient(cfg);
const me = await PlatformService.getMe(); const me = await PlatformService.getMe();
if (isJson(options)) {
printJson(me);
return;
}
success(`Signed in as ${me.user.email}`); success(`Signed in as ${me.user.email}`);
dim(`Config written to ${configFilePath()}`); dim(`Config written to ${configFilePath()}`);
} }
@@ -108,6 +126,23 @@ export async function cmdLogout(): Promise<void> {
export async function cmdWhoami(options: { json?: boolean }): Promise<void> { export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
const cfg = loadConfig(); const cfg = loadConfig();
configureClient(cfg); configureClient(cfg);
if (cfg.auth?.type === "apiKey") {
const ctx = await PlatformService.getActorContext();
if (isJson(options)) {
printJson(ctx);
return;
}
printTable(["KEY", "SCOPES", "ORGANIZATION", "PROJECT"], [
[
`${ctx.key_prefix}…`,
ctx.scopes?.join(", ") || "—",
ctx.organization?.slug ?? "—",
ctx.project?.slug ?? "—",
],
]);
dim(`\n${cfg.baseUrl} · auth: api_key`);
return;
}
const me = await PlatformService.getMe(); const me = await PlatformService.getMe();
if (isJson(options)) { if (isJson(options)) {
+21 -2
View File
@@ -1,8 +1,8 @@
import { CreateBucketRequest, StorageService } from "@primora/api-client"; import { CreateBucketRequest, StorageService } from "@primora/api-client";
import { loadConfig } from "../config.js"; import { loadConfig } from "../config.js";
import { configureClient, requireAuth, requireProject } from "../http.js"; import { configureClient, requireAuth, requireProject, resolveBucketId } from "../http.js";
import { isJson, printJson, printTable, success } from "../out.js"; import { confirmOrAbort, isJson, printJson, printTable, success } from "../out.js";
export async function cmdBucketsList(options: { export async function cmdBucketsList(options: {
project?: string; project?: string;
@@ -54,3 +54,22 @@ export async function cmdBucketsCreate(
} }
success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`); success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`);
} }
export async function cmdBucketsRemove(
bucketRef: string,
options: { project?: string; yes?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const bucketId = await resolveBucketId(projectId, bucketRef);
await confirmOrAbort(`Delete bucket "${bucketRef}" and all its objects?`, options.yes);
await StorageService.deleteBucket({ bucketId });
if (isJson(options)) {
printJson({ deleted: bucketId });
} else {
success(`deleted bucket ${bucketRef}`);
}
}
+29
View File
@@ -23,6 +23,22 @@ export async function cmdUse(options: UseOptions): Promise<void> {
requireAuth(cfg); requireAuth(cfg);
configureClient(cfg); configureClient(cfg);
// API keys are bound to one org+project — resolve the scope directly.
if (cfg.auth?.type === "apiKey") {
const ctx = await PlatformService.getActorContext();
if (options.org && ctx.organization && options.org !== ctx.organization.id && options.org !== ctx.organization.slug) {
throw new Error(`API key is scoped to ${ctx.organization.slug} — cannot switch to "${options.org}".`);
}
if (options.project && ctx.project && options.project !== ctx.project.id && options.project !== ctx.project.slug) {
throw new Error(`API key is scoped to ${ctx.project.slug} — cannot switch to "${options.project}".`);
}
if (ctx.organization) cfg.organizationId = ctx.organization.id;
if (ctx.project) cfg.projectId = ctx.project.id;
saveConfig(cfg);
success(`Context → ${ctx.organization?.slug ?? "—"} / ${ctx.project?.slug ?? "—"}`);
return;
}
const me = await PlatformService.getMe(); const me = await PlatformService.getMe();
const orgs = me.organizations; const orgs = me.organizations;
if (orgs.length === 0) { if (orgs.length === 0) {
@@ -77,6 +93,19 @@ export async function cmdContext(options: { json?: boolean }): Promise<void> {
requireAuth(cfg); requireAuth(cfg);
configureClient(cfg); configureClient(cfg);
if (cfg.auth?.type === "apiKey") {
const ctx = await PlatformService.getActorContext();
if (isJson(options)) {
printJson({ baseUrl: cfg.baseUrl, auth: "apiKey", ...ctx });
return;
}
dim(`url ${cfg.baseUrl}`);
dim(`auth api_key (${ctx.key_prefix}…, scopes: ${ctx.scopes?.join(", ") || "none"})`);
process.stdout.write(`org ${ctx.organization ? `${ctx.organization.name} (${ctx.organization.slug})` : "—"}\n`);
process.stdout.write(`project ${ctx.project ? `${ctx.project.name} (${ctx.project.slug})` : "—"}\n`);
return;
}
const me = await PlatformService.getMe(); const me = await PlatformService.getMe();
const org = me.organizations.find((o) => o.id === cfg.organizationId); const org = me.organizations.find((o) => o.id === cfg.organizationId);
const project = org?.projects.find((pr) => pr.id === cfg.projectId); const project = org?.projects.find((pr) => pr.id === cfg.projectId);
+64
View File
@@ -0,0 +1,64 @@
import { CollectionsService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
import { formatDate, isJson, printJson, printTable } from "../out.js";
interface DocumentsListOptions {
project?: string;
collection?: string;
filter?: string;
order?: string;
limit?: number;
offset?: number;
json?: boolean;
}
// Accepts a collection id or slug.
async function collectionIdFor(projectId: string, ref: string): Promise<string> {
const { items } = await CollectionsService.listCollections({ projectId });
const match = items.find((c) => c.id === ref || c.slug === ref || c.name === ref);
if (!match) {
throw new CliError(
`Collection "${ref}" not found.`,
`Known: ${items.map((c) => c.slug).join(", ") || "(none)"}`,
);
}
return match.id;
}
export async function cmdDocumentsList(options: DocumentsListOptions): Promise<void> {
if (!options.collection) {
throw new CliError("Missing --collection <slug|id>.");
}
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const collectionId = await collectionIdFor(projectId, options.collection);
const res = await CollectionsService.listDocuments({
collectionId,
limit: options.limit,
offset: options.offset,
filter: options.filter,
order: options.order,
});
if (isJson(options)) {
printJson(res);
return;
}
if (!res.items.length) {
console.log("No documents.");
return;
}
printTable(
["ID", "DATA", "UPDATED"],
res.items.map((d) => [
d.id.slice(0, 8),
JSON.stringify(d.data).slice(0, 80),
formatDate(d.updated_at),
]),
);
console.log(`${res.items.length} of ${res.total}`);
}
+48
View File
@@ -0,0 +1,48 @@
import { AutomationService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
import { isJson, printJson } from "../out.js";
interface EventsSendOptions {
project?: string;
data?: string;
json?: boolean;
}
export async function cmdEventsSend(type: string, options: EventsSendOptions): Promise<void> {
if (!type.startsWith("custom.")) {
throw new CliError(
`Invalid event type "${type}".`,
"Client events must live in the custom.* namespace — e.g. custom.deploy.done",
);
}
let data: Record<string, unknown> = {};
if (options.data) {
try {
const parsed = JSON.parse(options.data);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new CliError("--data must be a JSON object.", `Got: ${options.data.slice(0, 60)}`);
}
data = parsed;
} catch (e) {
if (e instanceof CliError) throw e;
throw new CliError("--data is not valid JSON.", (e as Error).message);
}
}
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const res = await AutomationService.publishProjectEvent({
projectId,
requestBody: { type, data },
});
if (isJson(options)) {
printJson(res);
return;
}
console.log(`Published ${res.published ?? type} — realtime subscribers, matching webhooks, and event_pattern functions fire.`);
}
+61
View File
@@ -0,0 +1,61 @@
import { spawnSync } from "node:child_process";
import { CliError } from "../http.js";
import { dim } from "../out.js";
import { parseEnvFile } from "./secrets.js";
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
const REF_RE = /^primora:\/\/(.+)$/;
/**
* `primora inject [--all] [--env-file PATH] -- <cmd>` — run one child process
* with vault secrets in its environment. Nothing written to disk, nothing in
* argv; the parent shell is unchanged. `primora://NAME` values inside the
* env-file resolve against the vault; literals stay literals.
*/
export async function cmdInject(
options: VaultPasswordOptions & { all?: boolean; envFile?: string },
): Promise<void> {
// cac does not model `--` passthrough — slice it out of argv directly.
const sep = process.argv.indexOf("--");
const child = sep >= 0 ? process.argv.slice(sep + 1) : [];
if (!child.length) {
throw new CliError("No command given.", "Usage: primora inject [--all] [--env-file .env] -- <cmd>");
}
const vault = await unlockVault(options);
const env: Record<string, string> = {};
// Default: whole vault. --all exists for readability when --env-file is also used.
if (options.all || !options.envFile) {
for (const [k, e] of Object.entries(vault.data.secrets)) env[k] = e.value;
}
if (options.envFile) {
for (const [key, raw] of Object.entries(parseEnvFile(options.envFile))) {
const ref = REF_RE.exec(raw);
if (!ref) {
env[key] = raw;
continue;
}
const entry = vault.data.secrets[ref[1]];
if (!entry) throw new CliError(`Unresolved ref: primora://${ref[1]} in ${options.envFile}`);
env[key] = entry.value;
}
}
dim(`injecting ${Object.keys(env).length} var${Object.keys(env).length === 1 ? "" : "s"} → ${child[0]}`);
const res = spawnSync(child[0], child.slice(1), {
env: { ...process.env, ...env } as NodeJS.ProcessEnv,
stdio: "inherit",
});
if (res.error) throw new CliError(`Failed to start ${child[0]}: ${res.error.message}`);
process.exit(res.status ?? 1);
}
/** Used by `stack up --vault`: render all vault entries as .env lines. */
export async function vaultAsEnvFile(
options: VaultPasswordOptions,
): Promise<Record<string, string>> {
const vault = await unlockVault(options);
return Object.fromEntries(Object.entries(vault.data.secrets).map(([k, e]) => [k, e.value]));
}
+29 -1
View File
@@ -2,7 +2,7 @@ import { statSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises"; import { readFile, writeFile } from "node:fs/promises";
import { basename } from "node:path"; import { basename } from "node:path";
import { StorageService } from "@primora/api-client"; import { PresignObjectRequest, StorageService } from "@primora/api-client";
import { loadConfig } from "../config.js"; import { loadConfig } from "../config.js";
import { import {
@@ -133,3 +133,31 @@ export async function cmdObjectsRemove(
await StorageService.deleteBucketObject({ bucketId, objectKey }); await StorageService.deleteBucketObject({ bucketId, objectKey });
success(`Deleted ${objectKey}`); success(`Deleted ${objectKey}`);
} }
export async function cmdObjectsPresign(
bucketRef: string,
objectKey: string,
options: { project?: string; upload?: boolean; ttl?: string; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const bucketId = await bucketIdFor(cfg, bucketRef, options.project);
const presigned = await StorageService.presignBucketObject({
bucketId,
requestBody: {
key: objectKey,
op: options.upload
? PresignObjectRequest.op.UPLOAD
: PresignObjectRequest.op.DOWNLOAD,
ttl_seconds: options.ttl ? Number(options.ttl) : undefined,
},
});
if (isJson(options)) {
printJson(presigned);
return;
}
console.log(presigned.url);
console.error(`# ${presigned.method} — expires ${presigned.expires_at}`);
}
+28
View File
@@ -38,3 +38,31 @@ export async function cmdOrgsCreate(
} }
success(`Organization "${org.name}" created (${org.slug})`); success(`Organization "${org.name}" created (${org.slug})`);
} }
export async function cmdOrgsRemove(
orgRef: string,
options: { yes?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
let organizationId = orgRef;
if (!isUuid(orgRef)) {
const me = await PlatformService.getMe();
const found = me.organizations.find((o) => o.id === orgRef || o.slug === orgRef || o.name === orgRef);
if (!found) throw new Error(`Organization "${orgRef}" not found — pass its id directly.`);
organizationId = found.id;
}
await confirmOrAbort(`Delete organization "${orgRef}"? Every project inside goes with it.`, options.yes);
await OrganizationsService.deleteOrganization({ organizationId });
if (isJson(options)) {
printJson({ deleted: organizationId });
} else {
success(`deleted organization ${orgRef}`);
}
}
function isUuid(v: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}
+31
View File
@@ -50,3 +50,34 @@ export async function cmdProjectsCreate(
} }
success(`Project "${project.name}" created (${project.slug})`); success(`Project "${project.name}" created (${project.slug})`);
} }
export async function cmdProjectsRemove(
projectRef: string,
options: { org?: string; yes?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
let projectId = projectRef;
if (!isUuid(projectRef)) {
const me = await PlatformService.getMe();
const orgs = cfg.auth?.type === "apiKey" ? [] : me.organizations;
const found = orgs
.flatMap((o) => o.projects.map((pr) => ({ ...pr, orgId: o.id })))
.find((pr) => pr.id === projectRef || pr.slug === projectRef || pr.name === projectRef);
if (!found) throw new Error(`Project "${projectRef}" not found — pass its id directly.`);
projectId = found.id;
}
await confirmOrAbort(`Delete project "${projectRef}"? Buckets, secrets, and functions go with it.`, options.yes);
await ProjectsService.deleteProject({ projectId });
if (isJson(options)) {
printJson({ deleted: projectId });
} else {
success(`deleted project ${projectRef}`);
}
}
function isUuid(v: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}
+317
View File
@@ -0,0 +1,317 @@
import { readFileSync } from "node:fs";
import * as p from "@clack/prompts";
import { SecretsService, type ProjectSecret } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
import { dim, formatDate, isJson, printJson, printTable, success, withSpinner } from "../out.js";
import { saveVault } from "../vault.js";
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
// Env-safe names only: `inject` and `stack --vault` place entries into a child
// environment, so a name that is not a valid env var would fail there anyway.
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
function checkName(name: string): void {
if (!NAME_RE.test(name)) {
throw new CliError(
`Invalid secret name "${name}".`,
"Use env-var-safe names: [A-Za-z_][A-Za-z0-9_]*",
);
}
}
// Remote secrets live in the project's server-side store (AES-256-GCM at rest).
// `--remote` switches every secrets:* verb to it; no vault password is needed.
export interface RemoteOptions {
remote?: boolean;
project?: string;
}
function remoteProject(options: RemoteOptions): string {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
return requireProject(cfg, options.project);
}
function remoteRow(s: ProjectSecret): string[] {
return [s.name, s.url || "—", s.notes || "—", formatDate(s.updated_at)];
}
export interface SetOptions extends VaultPasswordOptions, RemoteOptions {
value?: string;
url?: string;
notes?: string;
}
export async function cmdSecretsSet(name: string, options: SetOptions): Promise<void> {
checkName(name);
let value = options.value;
if (value === undefined && !process.stdin.isTTY) {
value = readFileSync(0, "utf8").replace(/\r?\n$/, "");
}
if (value === undefined) {
const entered = await p.password({ message: `Value for ${name}` });
if (p.isCancel(entered)) {
p.cancel("Cancelled.");
process.exit(0);
}
value = entered;
}
if (options.remote) {
const projectId = remoteProject(options);
await SecretsService.setProjectSecret({
projectId,
name,
requestBody: { value, url: options.url, notes: options.notes },
});
success(`Set ${name} in the project vault`);
return;
}
const vault = await unlockVault(options);
const now = new Date().toISOString();
const existing = vault.data.secrets[name];
vault.data.secrets[name] = {
value,
url: options.url ?? existing?.url,
notes: options.notes ?? existing?.notes,
created_at: existing?.created_at ?? now,
updated_at: now,
};
await withSpinner("Encrypting vault…", async () => saveVault(vault));
success(`${existing ? "Updated" : "Set"} ${name}`);
}
async function pickRemote(projectId: string, message: string): Promise<string> {
const { items } = await SecretsService.listProjectSecrets({ projectId });
if (!items.length) {
dim("Project vault is empty — `primora secrets set NAME --remote`.");
process.exit(0);
}
const picked = await p.select({
message,
options: items.map((s) => ({ value: s.name, label: s.name, hint: s.url || s.notes || undefined })),
});
if (p.isCancel(picked)) {
p.cancel("Cancelled.");
process.exit(0);
}
return picked;
}
export async function cmdSecretsGet(
name: string | undefined,
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
): Promise<void> {
if (options.remote) {
const projectId = remoteProject(options);
name ??= await pickRemote(projectId, "Secret to reveal");
const res = await SecretsService.revealProjectSecret({ projectId, name });
if (isJson(options)) {
printJson({ name, value: res.value });
return;
}
process.stdout.write(res.value + "\n");
return;
}
const vault = await unlockVault(options);
if (!name) {
if (!process.stdin.isTTY) throw new CliError("Usage: primora secrets get <name>");
const names = Object.keys(vault.data.secrets).sort();
if (!names.length) {
dim("Vault is empty — `primora secrets set NAME`.");
return;
}
const picked = await p.select({
message: "Secret to print",
options: names.map((n) => ({
value: n,
label: n,
hint: vault.data.secrets[n].url ?? vault.data.secrets[n].notes ?? undefined,
})),
});
if (p.isCancel(picked)) {
p.cancel("Cancelled.");
process.exit(0);
}
name = picked;
}
const entry = vault.data.secrets[name];
if (!entry) throw new CliError(`Secret "${name}" not found.`);
if (isJson(options)) {
printJson({
name,
value: entry.value,
url: entry.url ?? null,
notes: entry.notes ?? null,
created_at: entry.created_at,
updated_at: entry.updated_at,
});
return;
}
process.stdout.write(entry.value + "\n");
}
export async function cmdSecretsList(
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
): Promise<void> {
if (options.remote) {
const projectId = remoteProject(options);
const { items } = await SecretsService.listProjectSecrets({ projectId });
if (isJson(options)) {
printJson(items);
return;
}
if (!items.length) {
dim("Project vault is empty — `primora secrets set NAME --remote`.");
return;
}
printTable(["NAME", "URL", "NOTES", "UPDATED"], items.map(remoteRow));
return;
}
const vault = await unlockVault(options);
const rows = Object.entries(vault.data.secrets).sort(([a], [b]) => a.localeCompare(b));
if (isJson(options)) {
printJson(
rows.map(([name, e]) => ({
name,
url: e.url ?? null,
notes: e.notes ?? null,
created_at: e.created_at,
updated_at: e.updated_at,
})),
);
return;
}
if (!rows.length) {
dim("Vault is empty — `primora secrets set NAME` or `primora secrets import .env`.");
return;
}
printTable(
["NAME", "URL", "UPDATED"],
rows.map(([name, e]) => [name, e.url ?? "—", formatDate(e.updated_at)]),
);
}
export async function cmdSecretsRemove(
name: string | undefined,
options: VaultPasswordOptions & RemoteOptions & { yes?: boolean },
): Promise<void> {
if (options.remote) {
const projectId = remoteProject(options);
name ??= await pickRemote(projectId, "Secret to delete");
if (!options.yes && process.stdin.isTTY) {
const confirmed = await p.confirm({ message: `Delete project secret ${name}?` });
if (p.isCancel(confirmed) || !confirmed) return;
}
await SecretsService.deleteProjectSecret({ projectId, name });
success(`Deleted ${name}`);
return;
}
const vault = await unlockVault(options);
if (!name) {
if (!process.stdin.isTTY) throw new CliError("Usage: primora secrets rm <name>");
const names = Object.keys(vault.data.secrets).sort();
if (!names.length) {
dim("Vault is empty.");
return;
}
const picked = await p.select({
message: "Secret to delete",
options: names.map((n) => ({ value: n, label: n })),
});
if (p.isCancel(picked)) {
p.cancel("Cancelled.");
process.exit(0);
}
name = picked;
}
if (!vault.data.secrets[name]) throw new CliError(`Secret "${name}" not found.`);
if (!options.yes && process.stdin.isTTY) {
const confirmed = await p.confirm({ message: `Delete secret ${name}?` });
if (p.isCancel(confirmed) || !confirmed) return;
}
delete vault.data.secrets[name];
await withSpinner("Encrypting vault…", async () => saveVault(vault));
success(`Deleted ${name}`);
}
/** Parse a .env file: KEY=value lines, optional `export`, quotes stripped. */
export function parseEnvFile(path: string): Record<string, string> {
const out: Record<string, string> = {};
for (const raw of readFileSync(path, "utf8").split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq < 0) continue;
const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
let val = line.slice(eq + 1).trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
out[key] = val;
}
return out;
}
export async function cmdSecretsImport(
file: string,
options: VaultPasswordOptions & RemoteOptions & { overwrite?: boolean },
): Promise<void> {
const parsed = parseEnvFile(file);
const entries = Object.entries(parsed).filter(([k]) => NAME_RE.test(k));
if (!entries.length) throw new CliError(`No valid KEY=value pairs in ${file}.`);
if (options.remote) {
const projectId = remoteProject(options);
const { items } = await SecretsService.listProjectSecrets({ projectId });
const existing = new Set(items.map((s) => s.name));
let added = 0;
let skipped = 0;
for (const [key, value] of entries) {
if (existing.has(key) && !options.overwrite) {
skipped++;
continue;
}
await SecretsService.setProjectSecret({ projectId, name: key, requestBody: { value } });
added++;
}
success(`Imported ${added} secret${added === 1 ? "" : "s"} into the project vault`);
if (skipped) dim(`Skipped ${skipped} existing (pass --overwrite to replace).`);
if (entries.length < Object.keys(parsed).length) {
dim("Skipped keys that are not env-var-safe.");
}
return;
}
const vault = await unlockVault(options);
const now = new Date().toISOString();
let added = 0;
let skipped = 0;
for (const [key, value] of entries) {
const existing = vault.data.secrets[key];
if (existing && !options.overwrite) {
skipped++;
continue;
}
vault.data.secrets[key] = {
value,
url: existing?.url,
notes: existing?.notes,
created_at: existing?.created_at ?? now,
updated_at: now,
};
added++;
}
await withSpinner("Encrypting vault…", async () => saveVault(vault));
success(`Imported ${added} secret${added === 1 ? "" : "s"} from ${file}`);
if (skipped) dim(`Skipped ${skipped} existing (pass --overwrite to replace).`);
if (entries.length < Object.keys(parsed).length) {
dim("Skipped keys that are not env-var-safe.");
}
}
+142
View File
@@ -0,0 +1,142 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { CliError } from "../http.js";
import { dim, success } from "../out.js";
import { vaultAsEnvFile } from "./inject.js";
import { parseEnvFile } from "./secrets.js";
import type { VaultPasswordOptions } from "./vault.js";
interface StackOptions extends VaultPasswordOptions {
dir?: string;
}
function projectDir(options: StackOptions): string {
const dir = options.dir ?? process.cwd();
for (const name of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) {
if (existsSync(join(dir, name))) return dir;
}
throw new CliError(
`No compose file in ${dir}.`,
"Run from a Primora deployment directory or pass --dir.",
);
}
function compose(dir: string, args: string[], env?: NodeJS.ProcessEnv): number {
const res = spawnSync("docker", ["compose", ...args], {
cwd: dir,
stdio: "inherit",
env: env ?? process.env,
});
if (res.error) {
throw new CliError(
`Failed to run docker compose: ${res.error.message}`,
"Docker with the Compose plugin is required.",
);
}
return res.status ?? 1;
}
export async function cmdStackUp(
options: StackOptions & { vault?: boolean; force?: boolean; detach?: boolean },
): Promise<void> {
const dir = projectDir(options);
const envPath = join(dir, ".env");
if (!options.vault) {
const code = compose(dir, ["up", "-d"]);
if (code !== 0) process.exit(code);
success("Stack is up.");
return;
}
// Vault mode: materialize .env for the duration of compose, then remove it.
// `env_file:` directives make compose require the file on disk — there is no
// flag to feed it via the process environment alone.
const secrets = await vaultAsEnvFile(options);
const hadEnv = existsSync(envPath);
if (hadEnv && !options.force) {
throw new CliError(
`${envPath} already exists.`,
"Import it (`primora secrets import .env --overwrite`), remove it, or pass --force to swap it for the vault during this run.",
);
}
const prior = hadEnv ? readFileSync(envPath) : undefined;
// compose env_file parsing is literal — write raw KEY=value lines.
const lines = Object.entries(secrets).map(([k, v]) => {
if (v.includes("\n")) throw new CliError(`Secret ${k} contains a newline — cannot be an env var.`);
return `${k}=${v}`;
});
try {
writeFileSync(envPath, lines.join("\n") + "\n", { mode: 0o600 });
const code = compose(dir, ["up", "-d"]);
if (code !== 0) process.exit(code);
success("Stack is up — .env restored/removed; secrets live in the vault only.");
} finally {
if (prior) writeFileSync(envPath, prior, { mode: 0o600 });
else if (existsSync(envPath)) unlinkSync(envPath);
}
}
export async function cmdStackDown(options: StackOptions): Promise<void> {
const dir = projectDir(options);
const code = compose(dir, ["down"]);
if (code !== 0) process.exit(code);
success("Stack stopped.");
}
export async function cmdStackStatus(options: StackOptions): Promise<void> {
const code = compose(projectDir(options), ["ps"]);
if (code !== 0) process.exit(code);
}
export async function cmdStackLogs(
service: string | undefined,
options: StackOptions & { follow?: boolean; tail?: string },
): Promise<void> {
const args = ["logs", "--tail", options.tail ?? "200"];
if (options.follow) args.push("--follow");
if (service) args.push(service);
const code = compose(projectDir(options), args);
if (code !== 0) process.exit(code);
}
export async function cmdStackPull(options: StackOptions): Promise<void> {
const dir = projectDir(options);
const code = compose(dir, ["pull"]);
if (code !== 0) process.exit(code);
success("Images updated — run `primora stack up` to apply.");
}
/** Postgres dump via `compose exec`; for a full backup (DB + storage volume)
* use scripts/backup.sh — it covers both. ponytail: no storage tar here. */
export async function cmdStackBackup(
options: StackOptions & { out?: string },
): Promise<void> {
const dir = projectDir(options);
const envFile = join(dir, ".env");
const env = existsSync(envFile) ? parseEnvFile(envFile) : {};
const user = env.POSTGRES_USER ?? "primora";
const db = env.POSTGRES_DB ?? "primora";
const outDir = options.out ?? join(dir, "backups");
mkdirSync(outDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const outPath = join(outDir, `primora-${stamp}.dump`);
const res = spawnSync(
"docker",
["compose", "exec", "-T", "postgres", "pg_dump", "-U", user, "-Fc", db],
{ cwd: dir, encoding: "buffer", maxBuffer: 1024 * 1024 * 1024 },
);
if (res.error || res.status !== 0) {
throw new CliError(
`pg_dump failed: ${res.error?.message ?? res.stderr?.toString().slice(0, 300) ?? `exit ${res.status}`}`,
"Is the stack running? `primora stack status`",
);
}
writeFileSync(outPath, res.stdout as Buffer, { mode: 0o600 });
success(`Backup written to ${outPath}`);
dim("Postgres only — scripts/backup.sh also archives the storage volume.");
}
+166
View File
@@ -0,0 +1,166 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import { CliError } from "../http.js";
import { dim, isJson, printJson, success, withSpinner } from "../out.js";
import {
clearSession,
createVault,
loadSessionKey,
openVault,
openVaultWithKey,
saveSessionKey,
sessionExpiry,
vaultExists,
vaultHeader,
vaultPath,
type OpenVault,
} from "../vault.js";
export interface VaultPasswordOptions {
passwordFile?: string;
}
async function promptPassword(confirm: boolean): Promise<Uint8Array> {
const entered = await p.password({ message: "Vault password" });
if (p.isCancel(entered)) {
p.cancel("Cancelled.");
process.exit(0);
}
if (confirm) {
const again = await p.password({ message: "Confirm vault password" });
if (p.isCancel(again)) {
p.cancel("Cancelled.");
process.exit(0);
}
if (again !== entered) throw new CliError("Passwords do not match.");
}
return new TextEncoder().encode(entered);
}
async function readPassword(options: VaultPasswordOptions): Promise<Uint8Array | undefined> {
const env = process.env.PRIMORA_VAULT_PASSWORD;
if (env) return new TextEncoder().encode(env);
if (options.passwordFile) {
const { readFileSync } = await import("node:fs");
const pw = readFileSync(options.passwordFile, "utf8").replace(/\r?\n$/, "");
return new TextEncoder().encode(pw);
}
return undefined;
}
/**
* Open the vault: live session key → PRIMORA_VAULT_PASSWORD → --password-file →
* hidden prompt. The session is how agents and scripts use the vault without
* ever seeing the password; everything else still proves the password.
*/
export async function unlockVault(options: VaultPasswordOptions): Promise<OpenVault> {
const session = loadSessionKey();
if (session) return openVaultWithKey(session);
const password = await readPassword(options);
if (password) return withSpinner("Unlocking vault…", async () => openVault(password));
if (!process.stdin.isTTY) {
throw new CliError(
"Vault is locked.",
"Run `primora vault unlock`, or set PRIMORA_VAULT_PASSWORD / pass --password-file.",
);
}
const prompted = await promptPassword(false);
return withSpinner("Unlocking vault…", async () => openVault(prompted));
}
export async function cmdVaultInit(options: VaultPasswordOptions): Promise<void> {
let password = await readPassword(options);
if (!password) {
if (!process.stdin.isTTY) {
throw new CliError(
"Vault password required.",
"Set PRIMORA_VAULT_PASSWORD or pass --password-file in non-interactive mode.",
);
}
password = await promptPassword(true);
}
await withSpinner("Creating vault…", async () => createVault(password));
success(`Vault created at ${vaultPath()}`);
dim("Secrets are encrypted with Argon2id + XChaCha20-Poly1305. The password is not recoverable.");
}
export async function cmdVaultUnlock(
options: VaultPasswordOptions & { ttl?: string },
): Promise<void> {
const session = loadSessionKey();
if (session) {
const exp = sessionExpiry();
dim(`Already unlocked — expires ${exp ? new Date(exp * 1000).toISOString() : "soon"}.`);
return;
}
const password =
(await readPassword(options)) ??
(process.stdin.isTTY
? await promptPassword(false)
: (() => {
throw new CliError(
"Vault password required.",
"Set PRIMORA_VAULT_PASSWORD or pass --password-file.",
);
})());
const vault = await withSpinner("Unlocking vault…", async () => openVault(password)); // proves the password before stashing the key
const ttl = saveSessionKey(vault.key, Number(options.ttl ?? 900));
success(`Vault unlocked for ${ttl}s — secrets usable without the password until expiry.`);
dim("`primora vault lock` revokes immediately.");
}
export async function cmdVaultLock(): Promise<void> {
if (clearSession()) success("Vault locked — session revoked.");
else dim("No active session.");
}
export async function cmdVaultStatus(options: { json?: boolean }): Promise<void> {
const exists = vaultExists();
const header = exists ? vaultHeader() : undefined;
const exp = sessionExpiry();
const unlocked = exp !== undefined;
let count: number | undefined;
if (unlocked) {
try {
count = Object.keys(openVaultWithKey(loadSessionKey()!).data.secrets).length;
} catch {
// session key no longer matches (vault re-created) — report locked
}
}
if (isJson(options)) {
printJson({
path: vaultPath(),
exists,
unlocked,
expires_at: exp ?? null,
secrets: count ?? null,
kdf: header ?? null,
});
return;
}
if (!exists) {
dim(`No vault at ${vaultPath()} — run \`primora vault init\`.`);
return;
}
const remaining = exp ? Math.max(0, exp - Math.floor(Date.now() / 1000)) : 0;
const mins = Math.floor(remaining / 60);
const state = unlocked
? pc.green("● unlocked") +
pc.dim(` — ${mins ? `${mins}m ` : ""}${remaining % 60}s remaining`)
: pc.dim("● locked");
process.stdout.write(`vault ${state}\n\n`);
const rows: [string, string][] = [
["path", vaultPath()],
["kdf", `argon2id · ${header!.m} KiB · ${header!.t} passes · ${header!.p} lanes`],
];
if (unlocked) rows.push(["secrets", String(count ?? "?")]);
const width = Math.max(...rows.map(([k]) => k.length));
for (const [k, v] of rows) {
process.stdout.write(`${pc.dim(k.padEnd(width))} ${v}\n`);
}
if (!unlocked) dim(`\n\`primora vault unlock\` opens a session for agents and scripts.`);
}
+2
View File
@@ -60,6 +60,8 @@ export function configureClient(cfg: CliConfig): void {
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`; OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
OpenAPI.HEADERS = undefined; OpenAPI.HEADERS = undefined;
OpenAPI.TOKEN = undefined; OpenAPI.TOKEN = undefined;
// encodeURI leaves ?#& intact in path params — smuggles query segments.
OpenAPI.ENCODE_PATH = encodeURIComponent;
if (cfg.auth?.type === "apiKey") { if (cfg.auth?.type === "apiKey") {
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key }; OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
+35 -1
View File
@@ -1,3 +1,4 @@
import * as p from "@clack/prompts";
import pc from "picocolors"; import pc from "picocolors";
export function isJson(options: { json?: boolean }): boolean { export function isJson(options: { json?: boolean }): boolean {
@@ -16,9 +17,27 @@ export function dim(message: string): void {
process.stdout.write(pc.dim(message) + "\n"); process.stdout.write(pc.dim(message) + "\n");
} }
/** Destructive-action gate — requires --yes in scripts, prompts on a TTY. */
export async function confirmOrAbort(message: string, yes?: boolean): Promise<void> {
if (yes) return;
if (!process.stdin.isTTY) {
throw new Error(`${message} — pass --yes to confirm in non-interactive mode`);
}
const answer = await p.confirm({ message });
if (p.isCancel(answer) || !answer) {
p.cancel("Aborted.");
process.exit(1);
}
}
export function fail(error: unknown): never { export function fail(error: unknown): never {
const err = error as { message?: string; hint?: string; status?: number; body?: unknown }; const err = error as { message?: string; hint?: string; status?: number; body?: unknown; cause?: { code?: string } };
let message = err?.message ?? "unknown error"; let message = err?.message ?? "unknown error";
// undici TypeError "fetch failed" — the useful part is the cause code.
if (message === "fetch failed" || message.startsWith("fetch failed")) {
const code = err?.cause?.code;
message = `could not reach the instance${code ? ` (${code})` : ""} — check \`primora context\` and that it is running`;
}
// generated client throws ApiError with a parsed body // generated client throws ApiError with a parsed body
const body = err?.body as { error?: { message?: string } } | undefined; const body = err?.body as { error?: { message?: string } } | undefined;
if (body?.error?.message) message = body.error.message; if (body?.error?.message) message = body.error.message;
@@ -28,6 +47,21 @@ export function fail(error: unknown): never {
process.exit(1); process.exit(1);
} }
/** Spinner for multi-hundred-ms work (Argon2 derives). Silent when piped. */
export async function withSpinner<T>(message: string, work: () => Promise<T>): Promise<T> {
if (!process.stdout.isTTY) return work();
const s = p.spinner();
s.start(message);
try {
const result = await work();
s.stop(message);
return result;
} catch (error) {
s.stop(pc.red("failed"));
throw error;
}
}
/** Minimal column printer — left-aligned, two-space gutter. */ /** Minimal column printer — left-aligned, two-space gutter. */
export function printTable(headers: string[], rows: string[][]): void { export function printTable(headers: string[], rows: string[][]): void {
const all = headers.length ? [headers, ...rows] : rows; const all = headers.length ? [headers, ...rows] : rows;
+247
View File
@@ -0,0 +1,247 @@
// Local encrypted vault for CLI-held secrets (API keys, deployment .env values).
// Clean-room format — inspired by arca-core's design goals, not its code (AGPL).
//
// File layout (all multi-byte ints little-endian):
// 8B magic "PRMVLT01"
// 1B kdf id (0x01 = argon2id)
// 4B m (KiB) 4B t 4B p
// 1B aead id (0x01 = xchacha20poly1305)
// 16B salt 24B nonce
// … ciphertext+tag of JSON payload; the full header is the AEAD AAD,
// so no parameter can be altered without failing decryption.
//
// Session: `vault unlock` writes vault.session (0600, TTL'd) holding the
// derived key, so agents/scripts use the vault without the password.
// `vault lock` or expiry removes it. The session file is the same-user risk
// equivalent of a plaintext .env for its lifetime — bounded by the TTL.
//
// ponytail: single credential slot, no duress/decoy, no Shamir — those are
// coercion-resistance features, not a CLI secrets problem.
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
import { argon2id } from "@noble/hashes/argon2.js";
import { CliError } from "./http.js";
export interface SecretEntry {
value: string;
url?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface VaultData {
v: 1;
secrets: Record<string, SecretEntry>;
}
const MAGIC = "PRMVLT01";
const KDF_ARGON2ID = 0x01;
const AEAD_XCHACHA = 0x01;
const HEADER_LEN = 8 + 1 + 4 + 4 + 4 + 1 + 16 + 24; // 62
// Floors, not targets — matching argon2id's current recommended memory cost.
const KDF = { m: 65536, t: 3, p: 4 } as const;
const enc = new TextEncoder();
const dec = new TextDecoder();
export function configDir(): string {
return (
process.env.PRIMORA_CONFIG_DIR ??
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "primora")
);
}
export function vaultPath(): string {
return process.env.PRIMORA_VAULT ?? join(configDir(), "vault.bin");
}
export function vaultExists(): boolean {
return existsSync(vaultPath());
}
/* ------------------------------------------------------------------ */
/* session */
/* ------------------------------------------------------------------ */
const SESSION_MAX_TTL = 86400; // 24 h ceiling
interface SessionFile {
key: string; // base64 data key
exp: number; // epoch seconds
}
function sessionPath(): string {
return process.env.PRIMORA_VAULT_SESSION ?? join(configDir(), "vault.session");
}
/** Session key if one exists and is unexpired; expired files are removed. */
export function loadSessionKey(): Uint8Array | undefined {
const path = sessionPath();
if (!existsSync(path)) return undefined;
try {
const s = JSON.parse(readFileSync(path, "utf8")) as SessionFile;
if (typeof s.key !== "string" || typeof s.exp !== "number") throw new Error("shape");
if (s.exp <= Date.now() / 1000) {
unlinkSync(path);
return undefined;
}
const key = Buffer.from(s.key, "base64");
if (key.length !== 32) throw new Error("bad key length");
return new Uint8Array(key);
} catch {
unlinkSync(path);
return undefined;
}
}
export function saveSessionKey(key: Uint8Array, ttlSeconds: number): number {
const ttl = Math.min(Math.max(60, ttlSeconds), SESSION_MAX_TTL);
const exp = Math.floor(Date.now() / 1000) + ttl;
mkdirSync(dirname(sessionPath()), { recursive: true });
writeFileSync(sessionPath(), JSON.stringify({ key: Buffer.from(key).toString("base64"), exp }), {
mode: 0o600,
});
return ttl;
}
export function clearSession(): boolean {
const path = sessionPath();
if (!existsSync(path)) return false;
unlinkSync(path);
return true;
}
export function sessionExpiry(): number | undefined {
const path = sessionPath();
if (!existsSync(path)) return undefined;
try {
const s = JSON.parse(readFileSync(path, "utf8")) as SessionFile;
return s.exp > Date.now() / 1000 ? s.exp : undefined;
} catch {
return undefined;
}
}
/* ------------------------------------------------------------------ */
/* format */
/* ------------------------------------------------------------------ */
/** Header fields readable without the password (for `vault status`). */
export function vaultHeader(): { m: number; t: number; p: number } | undefined {
const path = vaultPath();
if (!existsSync(path)) return undefined;
const buf = readFileSync(path);
if (buf.length < HEADER_LEN || buf.subarray(0, 8).toString("latin1") !== MAGIC) {
throw new CliError(`Not a primora vault: ${path}`);
}
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
return { m: dv.getUint32(9, true), t: dv.getUint32(13, true), p: dv.getUint32(17, true) };
}
interface ParsedFile {
buf: Buffer;
header: Buffer;
salt: Buffer;
nonce: Buffer;
params: { m: number; t: number; p: number };
}
function parseFile(): ParsedFile {
const path = vaultPath();
if (!existsSync(path)) {
throw new CliError("No vault found.", "Run `primora vault init` first.");
}
const buf = readFileSync(path);
if (buf.length < HEADER_LEN + 16 || buf.subarray(0, 8).toString("latin1") !== MAGIC) {
throw new CliError(`Not a primora vault: ${path}`);
}
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
if (dv.getUint8(8) !== KDF_ARGON2ID || dv.getUint8(21) !== AEAD_XCHACHA) {
throw new CliError("Unsupported vault parameters — newer format version?");
}
const params = { m: dv.getUint32(9, true), t: dv.getUint32(13, true), p: dv.getUint32(17, true) };
if (params.m < 8192 || params.t < 1 || params.p < 1) {
throw new CliError("Vault KDF parameters below floor — file is suspect.");
}
return {
buf,
header: buf.subarray(0, HEADER_LEN),
salt: buf.subarray(22, 38),
nonce: buf.subarray(38, 62),
params,
};
}
export interface OpenVault {
data: VaultData;
key: Uint8Array;
salt: Uint8Array;
params: { m: number; t: number; p: number };
}
function decrypt(f: ParsedFile, key: Uint8Array): VaultData {
try {
const plain = xchacha20poly1305(key, f.nonce, f.header).decrypt(f.buf.subarray(HEADER_LEN));
const data = JSON.parse(dec.decode(plain)) as VaultData;
if (data.v !== 1 || typeof data.secrets !== "object") throw new Error("shape");
return data;
} catch {
throw new CliError("Wrong password or corrupt vault.");
}
}
export function openVault(password: Uint8Array): OpenVault {
const f = parseFile();
const key = argon2id(password, f.salt, { ...f.params, dkLen: 32 });
return { data: decrypt(f, key), key, salt: f.salt, params: f.params };
}
/** Open with a session key — skips Argon2 entirely. */
export function openVaultWithKey(key: Uint8Array): OpenVault {
const f = parseFile();
return { data: decrypt(f, key), key, salt: f.salt, params: f.params };
}
/** Re-encrypt with the same salt/params and a fresh nonce. Atomic via tmp+rename. */
export function saveVault(open: OpenVault): void {
const path = vaultPath();
const nonce = crypto.getRandomValues(new Uint8Array(24));
const header = new Uint8Array(HEADER_LEN);
header.set(enc.encode(MAGIC), 0);
const dv = new DataView(header.buffer);
dv.setUint8(8, KDF_ARGON2ID);
dv.setUint32(9, open.params.m, true);
dv.setUint32(13, open.params.t, true);
dv.setUint32(17, open.params.p, true);
dv.setUint8(21, AEAD_XCHACHA);
header.set(open.salt, 22);
header.set(nonce, 38);
const plain = enc.encode(JSON.stringify(open.data));
const ct = xchacha20poly1305(open.key, nonce, header).encrypt(plain);
const tmp = `${path}.${process.pid}.tmp`;
mkdirSync(dirname(path), { recursive: true });
writeFileSync(tmp, Buffer.concat([header, Buffer.from(ct)]), { mode: 0o600 });
renameSync(tmp, path);
}
export function createVault(password: Uint8Array): void {
const path = vaultPath();
if (existsSync(path)) {
throw new CliError(
`Vault already exists: ${path}`,
"Use `primora secrets:*` — or remove the file to start over.",
);
}
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = argon2id(password, salt, { ...KDF, dkLen: 32 });
saveVault({ data: { v: 1, secrets: {} }, key, salt, params: KDF });
}
+33
View File
@@ -28,4 +28,37 @@ npm run bundle --workspace @primora/desktop # release bundles → src-tauri/ta
to be a Primora deployment, use the tray's Change Server. to be a Primora deployment, use the tray's Change Server.
- macOS blocks plain-HTTP loads to non-localhost hosts (ATS). Use HTTPS, or - macOS blocks plain-HTTP loads to non-localhost hosts (ATS). Use HTTPS, or
`http://localhost` for a local deployment. `http://localhost` for a local deployment.
## Secrets Vault
The tray menu's **Secrets Vault…** opens a local window (`ui/vault.html`) for
the CLI's encrypted vault — init, timed unlock/lock, list with copy-to-clipboard,
add/delete, `.env` import. Values are never displayed; copy goes straight to the
clipboard.
The desktop app does not reimplement the vault: IPC commands shell out to the
`primora` binary on PATH (`PRIMORA_CLI` overrides the binary path). One vault
file, one crypto implementation — terminal and desktop always agree. Requires
`npm i -g @primora/cli`; without it the window shows install instructions.
Injection and the credential broker stay CLI-only (`primora inject`,
`primora agent`).
- Deep links / push notifications: deliberately omitted — nothing needs them yet. - Deep links / push notifications: deliberately omitted — nothing needs them yet.
## Auto-updates
`tauri-plugin-updater` checks `releases/latest/download/latest.json` on the
GitHub repo when the connect screen loads; if a newer signed release exists
it offers "Update & restart" — download, minisign verification, install,
relaunch. No update manifest or signature failure = silent no-op; unsigned
artifacts can never install.
Release side (already wired in `.github/workflows/release.yml`): the desktop
job signs updater artifacts with `TAURI_SIGNING_PRIVATE_KEY` (+ optional
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`) repo secrets, uploads the `.sig`
files, and the release job assembles `latest.json` from them. Without the
secret the build still succeeds but emits no signatures — and no manifest.
The matching public key is embedded in `tauri.conf.json`. The private key
lives only in repo secrets and on the maintainer's machine — regenerate with
`npx tauri signer generate` and update both if it is ever lost.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@primora/desktop", "name": "@primora/desktop",
"version": "0.7.1", "version": "0.8.4",
"private": true, "private": true,
"description": "Primora desktop shell — thin Tauri client for a self-hosted deployment", "description": "Primora desktop shell — thin Tauri client for a self-hosted deployment",
"scripts": { "scripts": {
+457 -8
View File
@@ -47,6 +47,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -506,6 +515,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -529,7 +548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types",
"libc", "libc",
@@ -542,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation", "core-foundation 0.10.1",
"libc", "libc",
] ]
@@ -713,6 +732,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -996,6 +1026,16 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.12" version = "0.1.12"
@@ -1254,6 +1294,17 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@@ -1530,6 +1581,21 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@@ -1548,9 +1614,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1847,6 +1915,36 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.20",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -2036,6 +2134,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2288,6 +2392,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2303,6 +2408,18 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2366,6 +2483,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -2382,6 +2505,20 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -2615,7 +2752,9 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-process",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater",
] ]
[[package]] [[package]]
@@ -2804,15 +2943,20 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -2824,6 +2968,20 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -2852,6 +3010,79 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -2867,6 +3098,15 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -2924,6 +3164,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.36.1" version = "0.36.1"
@@ -3145,6 +3408,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@@ -3257,6 +3536,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.8" version = "1.0.8"
@@ -3320,6 +3605,27 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3341,7 +3647,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2", "block2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dbus", "dbus",
@@ -3351,7 +3657,7 @@ dependencies = [
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",
"gtk", "gtk",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -3384,6 +3690,17 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -3408,7 +3725,7 @@ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"image", "image",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"mime", "mime",
@@ -3504,6 +3821,32 @@ dependencies = [
"tauri-utils", "tauri-utils",
] ]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]] [[package]]
name = "tauri-plugin-single-instance" name = "tauri-plugin-single-instance"
version = "2.4.4" version = "2.4.4"
@@ -3520,6 +3863,39 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-updater"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.20",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
@@ -3530,7 +3906,7 @@ dependencies = [
"dpi", "dpi",
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"objc2", "objc2",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
@@ -3553,7 +3929,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -3742,6 +4118,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.19" version = "0.7.19"
@@ -4055,6 +4441,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4298,6 +4690,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -4483,6 +4884,17 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@@ -4528,6 +4940,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -4823,7 +5244,7 @@ dependencies = [
"gtk", "gtk",
"http", "http",
"javascriptcore-rs", "javascriptcore-rs",
"jni", "jni 0.21.1",
"libc", "libc",
"ndk", "ndk",
"objc2", "objc2",
@@ -4870,6 +5291,16 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
@@ -4984,6 +5415,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.5" version = "0.2.5"
@@ -5017,6 +5454,18 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.2",
"memchr",
]
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.8" version = "0.6.8"
+2
View File
@@ -15,5 +15,7 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -2,6 +2,6 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Default capabilities for the main window", "description": "Default capabilities for the main window",
"windows": ["main"], "windows": ["main", "vault"],
"permissions": ["core:default"] "permissions": ["core:default", "updater:default", "process:default"]
} }
+151 -3
View File
@@ -1,10 +1,13 @@
use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use tauri::{ use tauri::{
menu::{Menu, MenuItem}, menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, WindowEvent, AppHandle, Listener, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent,
}; };
/// Persisted shell state — one field, the deployment to load. /// Persisted shell state — one field, the deployment to load.
@@ -67,24 +70,165 @@ fn show_main(app: &AppHandle) {
} }
} }
fn open_vault(app: &AppHandle) {
if let Some(window) = app.get_webview_window("vault") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
return;
}
let _ = WebviewWindowBuilder::new(app, "vault", WebviewUrl::App("vault.html".into()))
.title("Primora — Secrets Vault")
.inner_size(780.0, 620.0)
.min_inner_size(560.0, 420.0)
.center()
.build();
}
/// The vault lives in the CLI — the desktop proxies the `primora` binary so
/// both surfaces operate on the same vault file, one crypto implementation.
/// Passwords travel via env, secret values via stdin; never via argv.
fn run_cli(args: &[&str], stdin_data: Option<&str>, password: Option<&str>) -> Result<String, String> {
let bin = std::env::var("PRIMORA_CLI").unwrap_or_else(|_| "primora".into());
let mut cmd = Command::new(&bin);
cmd.args(args)
.stdin(if stdin_data.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(pw) = password {
cmd.env("PRIMORA_VAULT_PASSWORD", pw);
}
let mut child = cmd.spawn().map_err(|_| {
"primora CLI not found on PATH — install it: npm i -g @primora/cli".to_string()
})?;
if let Some(data) = stdin_data {
child
.stdin
.as_mut()
.ok_or("failed to open child stdin")?
.write_all(data.as_bytes())
.map_err(|e| e.to_string())?;
}
let out = child.wait_with_output().map_err(|e| e.to_string())?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[tauri::command]
fn vault_probe() -> bool {
let bin = std::env::var("PRIMORA_CLI").unwrap_or_else(|_| "primora".into());
Command::new(bin)
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[tauri::command]
fn vault_status() -> Result<Value, String> {
let out = run_cli(&["vault", "status", "--json"], None, None)?;
serde_json::from_str(&out).map_err(|e| e.to_string())
}
#[tauri::command]
fn vault_secrets() -> Result<Value, String> {
let out = run_cli(&["secrets", "list", "--json"], None, None)?;
serde_json::from_str(&out).map_err(|e| e.to_string())
}
#[tauri::command]
fn vault_reveal(name: String) -> Result<String, String> {
run_cli(&["secrets", "get", &name], None, None)
}
#[tauri::command]
fn vault_init(password: String) -> Result<(), String> {
run_cli(&["vault", "init"], None, Some(&password)).map(|_| ())
}
#[tauri::command]
fn vault_unlock(password: String, ttl: u32) -> Result<(), String> {
let ttl = ttl.clamp(60, 86400).to_string();
run_cli(&["vault", "unlock", "--ttl", &ttl], None, Some(&password)).map(|_| ())
}
#[tauri::command]
fn vault_lock() -> Result<(), String> {
run_cli(&["vault", "lock"], None, None).map(|_| ())
}
#[tauri::command]
fn vault_set(
name: String,
value: String,
url: Option<String>,
notes: Option<String>,
) -> Result<(), String> {
let mut args = vec!["secrets", "set", name.as_str()];
if let Some(u) = url.as_deref().filter(|s| !s.is_empty()) {
args.extend(["--url", u]);
}
if let Some(n) = notes.as_deref().filter(|s| !s.is_empty()) {
args.extend(["--notes", n]);
}
run_cli(&args, Some(&value), None).map(|_| ())
}
#[tauri::command]
fn vault_rm(name: String) -> Result<(), String> {
run_cli(&["secrets", "rm", &name, "--yes"], None, None).map(|_| ())
}
#[tauri::command]
fn vault_import(path: String, overwrite: bool) -> Result<String, String> {
let mut args = vec!["secrets", "import", path.as_str()];
if overwrite {
args.push("--overwrite");
}
run_cli(&args, None, None)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
show_main(app); show_main(app);
})) }))
.invoke_handler(tauri::generate_handler![get_server_url, set_server_url]) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.invoke_handler(tauri::generate_handler![
get_server_url,
set_server_url,
vault_probe,
vault_status,
vault_secrets,
vault_reveal,
vault_init,
vault_unlock,
vault_lock,
vault_set,
vault_rm,
vault_import,
])
.setup(|app| { .setup(|app| {
let show = MenuItem::with_id(app, "show", "Show Primora", true, None::<&str>)?; let show = MenuItem::with_id(app, "show", "Show Primora", true, None::<&str>)?;
let vault_item = MenuItem::with_id(app, "vault", "Secrets Vault…", true, None::<&str>)?;
let change = MenuItem::with_id(app, "change_server", "Change Server…", true, None::<&str>)?; let change = MenuItem::with_id(app, "change_server", "Change Server…", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &change, &quit])?; let menu = Menu::with_items(app, &[&show, &vault_item, &change, &quit])?;
let mut tray = TrayIconBuilder::new() let mut tray = TrayIconBuilder::new()
.menu(&menu) .menu(&menu)
.tooltip("Primora") .tooltip("Primora")
.on_menu_event(|app, event| match event.id().as_ref() { .on_menu_event(|app, event| match event.id().as_ref() {
"show" => show_main(app), "show" => show_main(app),
"vault" => open_vault(app),
"change_server" => { "change_server" => {
// Clearing the URL and restarting lands back on the connect screen. // Clearing the URL and restarting lands back on the connect screen.
let _ = save_config(app, &DesktopConfig { server_url: None }); let _ = save_config(app, &DesktopConfig { server_url: None });
@@ -108,6 +252,10 @@ pub fn run() {
} }
let _tray = tray.build(app)?; let _tray = tray.build(app)?;
// The connect screen links here — "open the local Secrets Vault".
let handle = app.handle().clone();
app.listen("open-vault", move |_| open_vault(&handle));
// Close hides to the tray instead of quitting — Quit is on the menu. // Close hides to the tray instead of quitting — Quit is on the menu.
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let handle = window.clone(); let handle = window.clone();
+10 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Primora", "productName": "Primora",
"version": "0.7.1", "version": "0.8.4",
"identifier": "dev.tdvorak.primora", "identifier": "dev.tdvorak.primora",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -23,9 +23,18 @@
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http:; img-src 'self' data:; style-src 'self'" "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http:; img-src 'self' data:; style-src 'self'"
} }
}, },
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVERjFDMzI1MURGMTlBNDAKUldSQW12RWRKY1B4WFpQN2N0RjdCemllN1ptcDhObFJQMVdHL3pLTXMzNEN6TlJoMEZhQUhDNG4K",
"endpoints": [
"https://github.com/Dvorinka/Primora/releases/latest/download/latest.json"
]
}
},
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"createUpdaterArtifacts": true,
"category": "DeveloperTool", "category": "DeveloperTool",
"shortDescription": "Primora desktop client", "shortDescription": "Primora desktop client",
"longDescription": "Thin desktop shell for a self-hosted Primora deployment. Renders the console served by your own server.", "longDescription": "Thin desktop shell for a self-hosted Primora deployment. Renders the console served by your own server.",
+53 -3
View File
@@ -27,6 +27,11 @@
</form> </form>
<p id="err" class="err" hidden></p> <p id="err" class="err" hidden></p>
<p class="hint">The desktop app renders the console served by your own server. Nothing is hosted by us.</p> <p class="hint">The desktop app renders the console served by your own server. Nothing is hosted by us.</p>
<p class="hint"><a href="#" id="vault-link" class="vlink">Open the local Secrets Vault</a></p>
<div id="update" hidden>
<p class="hint" id="update-text"></p>
<button type="button" id="update-install">Update &amp; restart</button>
</div>
</main> </main>
<script type="module"> <script type="module">
@@ -43,13 +48,58 @@
button.textContent = "Connect"; button.textContent = "Connect";
}; };
// Updater — check before auto-connecting: a saved URL redirects
// immediately, so the check must resolve first or it never shows.
// Bounded at 5s; failures are silent (dev builds, offline, no
// manifest). Unsigned artifacts can never install — minisign is
// enforced by the plugin against the pubkey baked into the bundle.
const updateP = (tauri?.updater && tauri?.process)
? Promise.race([
tauri.updater.check().catch(() => null),
new Promise((r) => setTimeout(() => r(null), 5000)),
])
: Promise.resolve(null);
if (!tauri) { if (!tauri) {
fail("This page is meant to run inside the Primora desktop app."); fail("This page is meant to run inside the Primora desktop app.");
} else { } else {
// A saved deployment URL means "skip the form" on every launch. // A saved deployment URL means "skip the form" on every launch —
const saved = await tauri.core.invoke("get_server_url").catch(() => null); // unless an update is waiting, which wins the redirect.
if (saved) location.replace(saved); const update = await updateP;
if (update) {
const box = document.getElementById("update");
const text = document.getElementById("update-text");
const install = document.getElementById("update-install");
text.textContent = `Update available — v${update.version} (you have ${update.currentVersion}).`;
box.hidden = false;
install.addEventListener("click", async () => {
install.disabled = true;
install.textContent = "Downloading…";
try {
await update.downloadAndInstall();
await tauri.process.relaunch();
} catch (e) {
install.disabled = false;
install.textContent = "Update & restart";
text.textContent = `Update failed: ${e}`;
} }
});
// "Not now" continues to the saved server.
const later = document.createElement("button");
later.type = "button";
later.textContent = "Not now";
later.style.marginLeft = "0.5rem";
later.addEventListener("click", () => { const saved = _savedUrl; if (saved) location.replace(saved); });
box.appendChild(later);
}
const _savedUrl = await tauri.core.invoke("get_server_url").catch(() => null);
if (_savedUrl && !update) location.replace(_savedUrl);
}
document.getElementById("vault-link").addEventListener("click", (e) => {
e.preventDefault();
tauri?.event.emit("open-vault");
});
form.addEventListener("submit", async (event) => { form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
+219
View File
@@ -99,3 +99,222 @@ button:disabled {
font-size: 0.78rem; font-size: 0.78rem;
line-height: 1.5; line-height: 1.5;
} }
/* ---------- secrets vault window ---------- */
body.vault {
display: block;
padding: 0;
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #26292f;
background: #101216;
position: sticky;
top: 0;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.brand img {
width: 32px;
height: 32px;
border-radius: 8px;
}
.brand h1 {
font-size: 0.95rem;
font-weight: 600;
}
.bar-sub {
font-size: 0.72rem;
color: #6d6d76;
font-family: ui-monospace, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 24rem;
}
.bar-right {
display: flex;
align-items: center;
gap: 0.6rem;
}
.pill {
font-size: 0.75rem;
font-family: ui-monospace, monospace;
color: #6d6d76;
padding: 0.25rem 0.6rem;
border: 1px solid #26292f;
border-radius: 999px;
white-space: nowrap;
}
.pill.ok {
color: #34d399;
border-color: #1e3a2f;
}
.pill.warn {
color: #9a9aa3;
}
.view {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.vcard {
background: #14161b;
border: 1px solid #26292f;
border-radius: 14px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.vcard h2 {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.vcard select {
padding: 0.65rem 0.8rem;
background: #0b0d11;
border: 1px solid #2e3239;
border-radius: 8px;
color: inherit;
font-size: 0.9rem;
outline: none;
}
.vtable {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.vtable th {
text-align: left;
color: #6d6d76;
font-weight: 500;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid #26292f;
}
.vtable td {
padding: 0.55rem 0.5rem;
border-bottom: 1px solid #1e2025;
vertical-align: middle;
}
.vtable tr:last-child td {
border-bottom: none;
}
.mono {
font-family: ui-monospace, monospace;
font-size: 0.82rem;
}
.dimcell {
color: #9a9aa3;
max-width: 12rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.acts {
display: flex;
gap: 0.35rem;
justify-content: flex-end;
}
.ghost {
background: transparent;
border: 1px solid #2e3239;
color: #c9c9cf;
font-weight: 500;
}
.ghost:hover {
border-color: #f4693a;
color: #fff;
}
.ghost.sm {
padding: 0.25rem 0.55rem;
font-size: 0.75rem;
border-radius: 6px;
}
.danger:hover {
border-color: #ef6b6b;
color: #ef6b6b;
}
.vform {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.vform input {
flex: 1 1 8rem;
min-width: 0;
}
.vform input.grow {
flex: 3 1 14rem;
}
.vform button {
flex: 0 0 auto;
padding: 0.65rem 1rem;
}
.codeblock {
background: #0b0d11;
border: 1px solid #26292f;
border-radius: 8px;
padding: 0.8rem 1rem;
font-family: ui-monospace, monospace;
font-size: 0.82rem;
color: #e7e7ea;
line-height: 1.6;
}
.vlink {
color: #f4693a;
cursor: pointer;
font-size: 0.8rem;
}
.vlink:hover {
text-decoration: underline;
}
.dim {
color: #6d6d76;
font-size: 0.85rem;
}
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Primora — Secrets Vault</title>
<link rel="icon" type="image/svg+xml" href="icon.svg" />
<link rel="stylesheet" href="style.css" />
</head>
<body class="vault">
<header class="bar">
<div class="brand">
<img src="icon.svg" alt="" />
<div>
<h1>Secrets Vault</h1>
<p id="path" class="bar-sub"></p>
</div>
</div>
<div class="bar-right">
<span id="pill" class="pill"></span>
<button id="lock-btn" class="ghost" hidden>Lock</button>
</div>
</header>
<main id="view" class="view">
<p class="dim">Loading…</p>
</main>
<script type="module" src="vault.js"></script>
</body>
</html>
+247
View File
@@ -0,0 +1,247 @@
const tauri = window.__TAURI__;
const view = document.getElementById("view");
const pill = document.getElementById("pill");
const lockBtn = document.getElementById("lock-btn");
const pathEl = document.getElementById("path");
let countdownTimer = null;
const el = (tag, attrs = {}, ...children) => {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") node.className = v;
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
else if (v !== null && v !== undefined && v !== false) node.setAttribute(k, v);
}
for (const c of children) node.append(c);
return node;
};
const errBox = (msg) => el("p", { class: "err" }, msg);
const dimNote = (msg) => el("p", { class: "hint" }, msg);
function setPill(state, expiresAt) {
clearInterval(countdownTimer);
pill.className = "pill";
lockBtn.hidden = true;
if (state === "unlocked" && expiresAt) {
const tick = () => {
const left = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
const m = Math.floor(left / 60);
const s = left % 60;
pill.textContent = `● unlocked — ${m}m ${String(s).padStart(2, "0")}s`;
if (!left) refresh();
};
pill.classList.add("ok");
tick();
countdownTimer = setInterval(tick, 1000);
lockBtn.hidden = false;
} else {
pill.textContent = state === "none" ? "no vault" : "● locked";
if (state !== "none") pill.classList.add("warn");
}
}
function card(title, sub, ...children) {
return el("section", { class: "vcard" },
el("h2", {}, title),
sub ? el("p", { class: "sub" }, sub) : "",
...children);
}
function busy(btn, label) {
btn.disabled = true;
btn.dataset.label = btn.textContent;
btn.textContent = label;
}
const unbusy = (btn) => {
btn.disabled = false;
btn.textContent = btn.dataset.label || btn.textContent;
};
/* ---------- views ---------- */
function renderMissing() {
setPill("none");
pathEl.textContent = "";
view.replaceChildren(card(
"CLI required",
"The vault lives in the primora CLI — the desktop app drives the same vault file, so terminal and desktop always agree.",
el("pre", { class: "codeblock" }, "npm i -g @primora/cli\nprimora vault init"),
dimNote("Reopen this window after installing.")
));
}
function renderInit() {
setPill("none");
const pw = el("input", { type: "password", placeholder: "Vault password", autocomplete: "new-password" });
const pw2 = el("input", { type: "password", placeholder: "Confirm password", autocomplete: "new-password" });
const err = el("p", { class: "err", hidden: true });
const btn = el("button", {
onclick: async () => {
err.hidden = true;
if (pw.value.length < 8) { err.textContent = "Use at least 8 characters."; err.hidden = false; return; }
if (pw.value !== pw2.value) { err.textContent = "Passwords do not match."; err.hidden = false; return; }
busy(btn, "Creating…");
try { await tauri.core.invoke("vault_init", { password: pw.value }); }
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(btn); return; }
refresh();
},
}, "Create vault");
view.replaceChildren(card(
"Create your vault",
"Local, encrypted, Argon2id + XChaCha20-Poly1305. The password is not recoverable — lose it and the contents are gone.",
pw, pw2, btn, err
));
}
function renderLocked() {
setPill("locked");
const pw = el("input", { type: "password", placeholder: "Vault password", autocomplete: "current-password", autofocus: true });
const ttl = el("select", {},
el("option", { value: "900" }, "Unlock for 15 minutes"),
el("option", { value: "3600" }, "Unlock for 1 hour"),
el("option", { value: "28800" }, "Unlock for 8 hours"),
el("option", { value: "86400" }, "Unlock for 24 hours"));
const err = el("p", { class: "err", hidden: true });
const btn = el("button", {
onclick: async () => {
err.hidden = true;
busy(btn, "Unlocking…");
try { await tauri.core.invoke("vault_unlock", { password: pw.value, ttl: Number(ttl.value) }); }
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(btn); return; }
refresh();
},
}, "Unlock");
pw.addEventListener("keydown", (e) => { if (e.key === "Enter") btn.click(); });
view.replaceChildren(card(
"Vault is locked",
"Unlocks a timed session — agents and scripts can use secrets without the password until it expires.",
pw, ttl, btn, err
));
}
function secretRow(s) {
const copy = el("button", { class: "ghost sm", title: "Copy value" }, "copy");
copy.addEventListener("click", async () => {
try {
const value = await tauri.core.invoke("vault_reveal", { name: s.name });
await navigator.clipboard.writeText(value.trimEnd());
copy.textContent = "copied";
setTimeout(() => (copy.textContent = "copy"), 1200);
} catch (e) { copy.textContent = "error"; setTimeout(() => (copy.textContent = "copy"), 1500); }
});
const del = el("button", { class: "ghost sm danger", title: "Delete" }, "delete");
del.addEventListener("click", async () => {
if (del.dataset.armed) {
try { await tauri.core.invoke("vault_rm", { name: s.name }); } catch {}
refresh();
return;
}
del.dataset.armed = "1";
del.textContent = "sure?";
setTimeout(() => { delete del.dataset.armed; del.textContent = "delete"; }, 2500);
});
return el("tr", {},
el("td", { class: "mono" }, s.name),
el("td", {}, s.url ? el("span", {
class: "vlink",
title: `${s.url} — click to copy`,
onclick: async () => { await navigator.clipboard.writeText(s.url).catch(() => {}); },
}, s.url.replace(/^https?:\/\//, "").slice(0, 34)) : "—"),
el("td", { class: "dimcell", title: s.notes ?? "" }, (s.notes ?? "—").slice(0, 34)),
el("td", { class: "dimcell" }, (s.updated_at ?? "").slice(0, 10)),
el("td", { class: "acts" }, copy, del));
}
async function renderUnlocked(st) {
setPill("unlocked", st.expires_at);
const secrets = await tauri.core.invoke("vault_secrets").catch(() => []);
const table = secrets.length
? el("table", { class: "vtable" },
el("thead", {}, el("tr", {},
el("th", {}, "name"), el("th", {}, "url"), el("th", {}, "notes"),
el("th", {}, "updated"), el("th", {}, ""))),
el("tbody", {}, ...secrets.map(secretRow)))
: dimNote("Empty — add a secret below or import a .env file.");
// inline add form
const name = el("input", { placeholder: "NAME", class: "mono", spellcheck: "false" });
const value = el("input", { type: "password", placeholder: "value", autocomplete: "off" });
const url = el("input", { placeholder: "url (optional)", inputmode: "url" });
const notes = el("input", { placeholder: "notes (optional)" });
const err = el("p", { class: "err", hidden: true });
const addBtn = el("button", {
onclick: async () => {
err.hidden = true;
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name.value)) {
err.textContent = "Name must be env-safe: [A-Za-z_][A-Za-z0-9_]*";
err.hidden = false; return;
}
if (!value.value) { err.textContent = "Value required."; err.hidden = false; return; }
busy(addBtn, "Encrypting…");
try {
await tauri.core.invoke("vault_set", {
name: name.value, value: value.value, url: url.value || null, notes: notes.value || null,
});
} catch (e) { err.textContent = String(e); err.hidden = false; unbusy(addBtn); return; }
refresh();
},
}, "Add secret");
const importPath = el("input", { placeholder: "/path/to/.env", class: "mono grow" });
const importBtn = el("button", {
class: "ghost",
onclick: async () => {
err.hidden = true;
if (!importPath.value.trim()) return;
busy(importBtn, "Importing…");
try { await tauri.core.invoke("vault_import", { path: importPath.value.trim(), overwrite: false }); }
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(importBtn); return; }
refresh();
},
}, "Import .env");
view.replaceChildren(
el("section", { class: "vcard" },
el("h2", {}, `${secrets.length} secret${secrets.length === 1 ? "" : "s"}`),
table,
el("div", { class: "vform" }, name, value, url, notes, addBtn),
el("div", { class: "vform" }, importPath, importBtn),
err),
el("p", { class: "hint" },
"Values are never shown here — copy goes straight to the clipboard. " +
"Injection and the credential broker stay in the CLI: primora inject / primora agent.")
);
}
/* ---------- boot ---------- */
async function refresh() {
if (!tauri) {
view.replaceChildren(errBox("This page is meant to run inside the Primora desktop app."));
return;
}
const ok = await tauri.core.invoke("vault_probe").catch(() => false);
if (!ok) return renderMissing();
const st = await tauri.core.invoke("vault_status").catch((e) => ({ error: String(e) }));
if (st.error) { view.replaceChildren(errBox(st.error)); return; }
pathEl.textContent = st.path ?? "";
if (!st.exists) return renderInit();
if (!st.unlocked) return renderLocked();
renderUnlocked(st);
}
lockBtn.addEventListener("click", async () => {
await tauri.core.invoke("vault_lock").catch(() => {});
refresh();
});
// Keep the pill/table honest if the session expires while the window sits open.
setInterval(async () => {
const st = await tauri?.core.invoke("vault_status").catch(() => null);
if (st && !st.unlocked && !lockBtn.hidden) refresh();
}, 30000);
refresh();
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff. Show more