diff --git a/.env.example b/.env.example index 9ac12dd..76a8353 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,31 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1 BACKEND_INTERNAL_URL=http://backend:8080 BACKEND_STORAGE_ROOT=/data/storage +# Object storage driver: "local" (default, uses BACKEND_STORAGE_ROOT) or "s3". +# For s3, point S3_ENDPOINT at any S3-compatible API (AWS, MinIO, Garage, R2). +BACKEND_STORAGE_DRIVER=local +#S3_ENDPOINT=http://minio:9000 +#S3_REGION=us-east-1 +#S3_BUCKET=primora-objects +#S3_ACCESS_KEY_ID= +#S3_SECRET_ACCESS_KEY= +#S3_PREFIX= +#S3_PATH_STYLE=true +# Client-facing S3 endpoint for presigned URLs — set when S3_ENDPOINT is an +# internal address (e.g. http://minio:9000 inside compose) that browsers +# can't reach. Presigned URLs are valid only at the host they were minted for. +#S3_PUBLIC_ENDPOINT=https://objects.example.com + +# Functions execution. "exec" runs the runtime binary (bun/deno) directly on +# the backend host — simple, but function code inherits backend privileges. +# "docker" runs each invoke in an ephemeral locked-down container (no +# network, read-only fs, 128 MiB mem, non-root) and requires the Docker +# socket mounted into the backend plus the runtime images pulled. +#FUNCTIONS_DRIVER=exec +#FUNCTIONS_RUNTIME=auto +#FUNCTIONS_TIMEOUT_SECONDS=30 +#FUNCTIONS_MAX_OUTPUT_BYTES=65536 + # Mail defaults — each is overridable in-app (Settings → Instance). In-app # values win over these env vars; deleting the in-app value reverts here. RESEND_API_KEY= diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5efdd58..b8bb0d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,6 +146,11 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf - name: Build Tauri bundles + # Updater artifacts are minisign-signed; without the key the build + # still succeeds but emits no .sig files (updater can't verify them). + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: npm run bundle --workspace @primora/desktop - uses: actions/upload-artifact@v4 with: @@ -155,8 +160,11 @@ jobs: apps/desktop/src-tauri/target/release/bundle/**/*.deb apps/desktop/src-tauri/target/release/bundle/**/*.rpm apps/desktop/src-tauri/target/release/bundle/**/*.dmg + apps/desktop/src-tauri/target/release/bundle/**/*.app.tar.gz apps/desktop/src-tauri/target/release/bundle/**/*.msi apps/desktop/src-tauri/target/release/bundle/**/*.exe + apps/desktop/src-tauri/target/release/bundle/**/*.nsis.zip + apps/desktop/src-tauri/target/release/bundle/**/*.sig if-no-files-found: error npm-packages: @@ -225,6 +233,48 @@ jobs: flag ' CHANGELOG.md > notes.md [ -s notes.md ] || echo "Release ${VERSION}" > notes.md + # Tauri updater manifest: one platform entry per signed updater + # artifact (AppImage.sig / app.tar.gz.sig / nsis.zip.sig). Missing + # sigs mean TAURI_SIGNING_PRIVATE_KEY isn't set — then there is no + # manifest and no update, which is correct (never ship unsigned). + - name: Build updater manifest (latest.json) + run: | + python3 - <<'PY' + import json, os, glob + base = "https://github.com/Dvorinka/Primora/releases/download/%s" % os.environ["VERSION"] + platforms = {} + for sig in glob.glob("dist-release/**/*.sig", recursive=True): + asset = sig[:-4] + name = os.path.basename(asset) + if name.endswith(".AppImage"): + plat = "linux-x86_64" + elif name.endswith(".app.tar.gz"): + plat = "darwin-aarch64" if "aarch64" in name else "darwin-x86_64" + if "aarch64" not in name and "x86_64" not in name: + plat = "darwin-aarch64" # macos-latest is arm64 + elif name.endswith(".nsis.zip"): + plat = "windows-x86_64" + else: + continue + platforms[plat] = { + "signature": open(sig).read().strip(), + "url": f"{base}/{name}", + } + manifest = { + "version": os.environ["VERSION"], + "notes": "See the release notes.", + "pub_date": __import__("datetime").datetime.now( + __import__("datetime").timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%SZ"), + "platforms": platforms, + } + if platforms: + with open("dist-release/latest.json", "w") as f: + json.dump(manifest, f, indent=2) + print("latest.json:", ", ".join(platforms)) + else: + print("no signed updater artifacts — skipping latest.json") + PY - name: Create release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -234,6 +284,54 @@ jobs: mapfile -t files < <(find dist-release -type f) [ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; } gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}" + # The manifest must be fetchable at /releases/latest/download/latest.json + if [ -f dist-release/latest.json ]; then + gh release upload "$VERSION" dist-release/latest.json --clobber + fi + + # Software bill of materials — CycloneDX JSON per published image plus + # one for the source tree (npm + Go modules). Attached to the release + # next to the binaries; downstream consumers can verify provenance. + sbom: + name: SBOMs + needs: [verify, release] + if: needs.verify.outputs.needed == 'true' + runs-on: ubuntu-latest + permissions: + packages: read + env: + VERSION: ${{ needs.verify.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: anchore/sbom-action@v0 + with: + image: ghcr.io/${{ github.repository_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/${{ github.repository_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/${{ github.repository_owner }}/primora-frontend:${{ needs.verify.outputs.version }} + format: cyclonedx-json + output-file: dist-release/sbom-frontend.cdx.json + - uses: anchore/sbom-action@v0 + with: + path: . + format: cyclonedx-json + output-file: dist-release/sbom-source.cdx.json + - name: Attach SBOMs to the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "$VERSION" dist-release/sbom-*.cdx.json --clobber npm-publish: name: Publish to npm diff --git a/CHANGELOG.md b/CHANGELOG.md index 9328929..0b489e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,14 +11,47 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac - **`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. +- **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/` (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 --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 `*`, `.*`, 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 ` 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 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 `` the nginx CSP blocked (`style-src 'self'`). Removes the runtime Google dependency, works airgapped, and eliminates the console CSP violation. ## [0.7.1] - 2026-09-23 diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index 5dc1e93..cdc0693 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -41,7 +41,14 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d | `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes | | `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes | | `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes | -| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container | yes | +| `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..amazonaws.com`), region, bucket | with `s3` | +| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 credentials | with `s3` | +| `S3_PREFIX` / `S3_PATH_STYLE` | optional key prefix; path-style URLs (default `true`, needed by MinIO/Garage — set `false` for AWS virtual-hosted) | no | +| `S3_PUBLIC_ENDPOINT` | client-facing S3 endpoint used for presigned URLs when `S3_ENDPOINT` is internal-only (e.g. `http://minio:9000`); signatures bind the public host | no | +| `FUNCTIONS_DRIVER` | Function execution driver: `exec` (default — runtime binary on the backend host; code runs with backend privileges) or `docker` — each invoke runs in an ephemeral locked-down container (no network, read-only fs, 128 MiB, non-root). Docker mode needs `/var/run/docker.sock` mounted into the backend (see the commented volume in `docker-compose.yml`) and the runtime images pulled (`oven/bun`, `denoland/deno`). Source code is capped at 256 KiB under the docker driver | no | +| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` / `FUNCTIONS_MAX_OUTPUT_BYTES` | Functions: runtime binary (`auto` resolves `bun`/`deno` — exec driver only; docker driver uses the matching official image), per-run timeout (default 30 s), captured-output cap (default 64 KiB) | no | | `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no | | `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production | @@ -127,10 +134,27 @@ docker compose up -d # 3. rolling recreate; backend migrate ## Storage constraint -Object storage is the **local filesystem** (`backend_storage` volume). There -is no S3-compatible backend yet — the volume must be on durable, backed-up -disk, and a single host serves all objects. Plan capacity accordingly; -multi-node deployments need an external object store first. +Object storage defaults to the **local filesystem** (`backend_storage` +volume) — durable, backed-up disk on a single host. For multi-node or +external object stores set `BACKEND_STORAGE_DRIVER=s3` + the `S3_*` +variables above (AWS, MinIO, Garage, R2 all work). + +## Functions trust model + +Two drivers, two trust levels: + +- **`exec` (default)** — function code runs as a child process of the + backend: same host, same privileges, no sandbox. Treat every project + member who can edit a function as holding a shell on the backend host. + Appropriate for single-team self-hosted deployments. +- **`docker`** — each invoke runs in an ephemeral container: no network, + read-only rootfs, 128 MiB memory, half a CPU, 64 pids, non-root, all + capabilities dropped. Requires the Docker socket mounted into the + backend (commented volume in `docker-compose.yml`) — note the socket is + itself root-equivalent on the host, so this isolates *function code*, + not operators. Source code capped at 256 KiB. For hostile multi-tenant + workloads, a dedicated microVM runtime (Firecracker/gVisor) remains the + roadmap item. ## Observability diff --git a/QUICK_START.md b/QUICK_START.md index 7dc12c9..7a593e4 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -65,6 +65,10 @@ The important ones: | `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app | | `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only | | `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app | +| `BACKEND_STORAGE_DRIVER` | `local` (default, `BACKEND_STORAGE_ROOT`) or `s3` — S3-compatible object storage | +| `S3_ENDPOINT` / `S3_BUCKET` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 backend config (with `S3_REGION`, `S3_PREFIX`, `S3_PATH_STYLE`) | +| `S3_PUBLIC_ENDPOINT` | Client-facing S3 host for presigned URLs when `S3_ENDPOINT` is internal-only | +| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` | JS runtime for Functions — `auto` (default, resolves `bun`/`deno` from PATH) or a binary path; 30 s default timeout | | `VITE_DEMO_MODE` | Enable the client-side demo workspace | ### Instance administration diff --git a/README.md b/README.md index d17b963..a3a44f5 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,12 @@ Supabase, with no hosted tier - your data stays on your hardware. - **Organizations & projects** - workspaces with scoped org and project roles. - **Auth** - email/password plus optional GitHub, Google, Discord, and Microsoft OAuth. JWTs verified by the API against JWKS. -- **Storage** - S3-style buckets on your local filesystem, public or private. -- **Collections** - schema-flexible JSON documents in Postgres JSONB. +- **Storage** - S3-style buckets on local disk or any S3-compatible backend (AWS, MinIO, Garage, R2), public or private. With `s3` the API mints presigned upload/download URLs so large objects skip the backend. +- **Collections** - schema-flexible JSON documents in Postgres JSONB, with filter/order queries. +- **Functions** - project-scoped JS/TS run by bun/deno — on the host (`exec`) or in ephemeral locked-down containers (`docker` driver) — with per-run logs. Trigger them four ways: manual invoke, cron schedules (`function_id` on a job), inbound hooks (`mode: "function"`), or domain events (`event_pattern` like `document.*`). +- **Vault** - AES-256-GCM project secrets plus an Argon2id/XChaCha20 local vault in the CLI and desktop app. +- **Automation** - scheduled jobs, inbound webhooks, telemetry alert rules, and `secret://` refs resolved at delivery. +- **Realtime** - SSE event stream with a typed client SDK (`@primora/client`) — channel subscriptions, presence counts, and client-published `custom.*` events that fan out to webhooks and functions. - **API keys** - `prm__` credentials, shown once. - **Audit log** - every mutating request recorded, with CSV/JSON export. - **Demo mode** - fully client-side seeded workspace via `?demo=true`. diff --git a/ROADMAP.md b/ROADMAP.md index 42c309c..9585b5d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,8 +123,11 @@ Follow-ups: - Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run (host-verified on Linux only). -- Desktop bundle signing + auto-updater — unsigned artifacts today; add when - distribution matures. +- ~~Desktop bundle signing + auto-updater~~ ✅ — `tauri-plugin-updater` + wired: signed updater artifacts (`createUpdaterArtifacts`), minisign + keypair, `latest.json` manifest assembled on release, in-app + "Update & restart" on the connect screen. macOS/Windows OS-level code + signing (notarization/Authenticode) still open — needs paid certs. ## Phase 6 — Production hardening (v0.6.0) ✅ @@ -185,8 +188,9 @@ release pipeline with images + binaries + desktop bundles. (auto-registers from ingest). - ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres → dragonfly transfer, key templating, TTL). -- ~~**External object storage**~~ ✅ — constraint documented in - DEPLOYMENT_GUIDE.md / PRODUCTION_READINESS.md; S3 backend deferred. +- ~~**External object storage**~~ ✅ — `BACKEND_STORAGE_DRIVER=s3` shipped + (Phase 10): any S3-compatible API + presigned URLs; local disk remains + the default. ### Testing @@ -205,8 +209,10 @@ release pipeline with images + binaries + desktop bundles. - ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via QEMU + buildx. - Carried forward from Phase 5 follow-ups: macOS/Windows bundle - verification, signing + auto-updater. -- Optional: SBOM + provenance attestation on release images. + verification; updater + minisign signing shipped (OS-level signing + remains open). +- ~~Optional: SBOM~~ ✅ — CycloneDX SBOMs per published image + source + tree, attached to every release (`anchore/sbom-action`). ### Docs @@ -304,28 +310,115 @@ Channel subscriptions over the Phase 7 SSE stream, in `@primora/client`. with capped backoff, `onStateChange` lifecycle. - `channel("documents").on("created", cb)` — namespaces `document`, `object`, `issue`, `deploy`, `"*"` for everything; trailing `s` optional. -- Presence and client-broadcast deliberately deferred — protocol question, - no consumer yet. +- **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 is the credential; optional + `X-Primora-Signature` HMAC-SHA256 verification when a secret is set. +- `event` mode republishes the body as `inbound.received` (webhooks + + realtime fan out for free); `job` mode enqueues a scheduled-job run with + the received body as payload (`triggered_by: "hook"`). +- 256 KiB body cap; `last_received_at` tracked; managed from Integrations. + +## HA scheduler (unreleased) + +Multi-replica safety for the job scheduler and alert evaluator. + +- PostgreSQL advisory locks (session-scoped, separate keys per component) + on a dedicated pinned pool connection; only the leader scans for due work. +- Manual runs and hook-triggered runs work on any replica — the lock gates + only the scheduled scan, so leadership loss never blocks on-demand work. +- Released on shutdown; failover verified with a live two-replica test — + the survivor acquired leadership and fired the due job. + +## Email surface (unreleased) + +Reusable templates + an operational send log. + +- `core.email_log` (migration 00014) — every send recorded: template, + recipient, subject, `sent`/`failed` status, error text. +- `Mailer` stays transport-only (Resend when configured, SMTP otherwise); + templates render subject + plaintext body. First template: `invitation`. +- `GET /projects/:id/emails` lists recent sends; Members page shows an + "Email log" card under pending invitations. ## Future phases — candidates Ordered loosely by leverage. None committed; each gets scoped when picked. -- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers. - Deno or Bun isolates in a sidecar; secrets via env, payload via stdin. - The job runner already supplies the scheduling half. -- **Phase 10 — Storage backends** — S3-compatible object storage behind - the existing bucket abstraction (deferred from Phase 6), plus - presigned-URL uploads for large files. -- **Notifications** — alert rules on telemetry (error-rate thresholds, - heartbeat silence) fanning out through the webhook dispatcher. -- **Inbound webhooks** — project-scoped ingest endpoints that turn - external events into domain events/jobs. -- **HA mode** — leader-elected scheduler + distributed locks so two - backend replicas don't double-fire jobs; Postgres advisory locks are - enough at this scale. -- **Email surface** — transactional templates + send log in the - dashboard, backed by the auth service's existing SMTP/Resend wiring. +- **Phase 9 — Functions** — ✅ shipped: `core.functions` + + `core.function_runs`, bun/deno exec runner (stdin payload, env injection, + capped output, timeout), invoke API + dashboard page. Triggers shipped: + scheduled jobs (`function_id` on a job), inbound hooks (`mode: + "function"`), and domain events (`event_pattern`, e.g. `document.*`). + Runs record trigger source (`manual`/`schedule`/`hook`/`event`). + Isolation shipped: `FUNCTIONS_DRIVER=docker` runs every invoke in an + ephemeral locked-down container (no network, read-only fs, non-root). + Remaining candidate: Firecracker/gVisor microVMs for hostile + multi-tenant workloads — docker socket trust is documented. +- **Phase 10 — Storage backends** — ✅ shipped: `BACKEND_STORAGE_DRIVER=s3` + + `S3_*` env vars, stdlib SigV4, verified against a real S3 server. + Presigned upload/download URLs shipped: `POST + /buckets/:id/object-presigns` mints SigV4 query-signed GET/PUT URLs + (`S3_PUBLIC_ENDPOINT` rewrites the client-facing host); local driver + returns a clear error — a presigned URL needs a real object API behind it. +- **HA mode** — ✅ shipped: PostgreSQL advisory-lock leadership for the + scheduler and alert evaluator; failover verified with two replicas. +- **Email surface** — ✅ shipped: templated sends + `core.email_log` + + dashboard card; more templates land as new flows need them. + +## Release hardening (unreleased) + +- **Desktop auto-updater** — `tauri-plugin-updater`: signed updater + artifacts, `latest.json` manifest assembled per release, in-app + "Update & restart" on the connect screen. Unsigned artifacts can never + install — no signature means no manifest. +- **SBOMs** — CycloneDX per GHCR image + source tree, attached to every + GitHub Release. +- Requires `TAURI_SIGNING_PRIVATE_KEY` repo secret (keypair generated + locally; private half never committed). + +## Sandboxed functions (unreleased) + +- `FUNCTIONS_DRIVER=docker` — every invoke runs in an ephemeral sibling + container: `--network none`, read-only rootfs, 128 MiB / 0.5 CPU / 64 + pids, non-root, `--cap-drop ALL`. Source travels as base64 env — no + bind mount, so Docker Desktop and remote daemons work. Timeout + force-removes the named container. +- `exec` remains the default for single-team deployments; the trust-model + doc describes both levels. + +## Client events + presence (unreleased) + +- `POST /projects/:id/events` — members/API keys publish `custom.*` + events through the standard fan-out (realtime + webhooks + functions); + system types can't be spoofed. +- Presence — per-project realtime subscriber counts; `presence.update` + broadcasts on join/leave (stream-only), `GET …/realtime/presence` + reads the count. SDK: `publish()` + `presence()`; CLI: `events:send`. --- diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 1dbaca7..1657c66 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -34,6 +34,9 @@ RUN apt-get update \ && chmod +x /usr/local/bin/dbx-mcp \ && rm /tmp/dbx-mcp.tar.gz +# Functions runtime — bun static binary from the official image (multi-arch). +COPY --from=oven/bun:1.3.14 /usr/local/bin/bun /usr/local/bin/bun + COPY --from=builder /primora-backend /usr/local/bin/primora-backend COPY --from=builder /app/db ./db COPY --from=builder /app/openapi ./openapi diff --git a/apps/backend/db/migrations/00012_alert_rules.sql b/apps/backend/db/migrations/00012_alert_rules.sql new file mode 100644 index 0000000..e4dc5b1 --- /dev/null +++ b/apps/backend/db/migrations/00012_alert_rules.sql @@ -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; diff --git a/apps/backend/db/migrations/00013_inbound_hooks.sql b/apps/backend/db/migrations/00013_inbound_hooks.sql new file mode 100644 index 0000000..b248972 --- /dev/null +++ b/apps/backend/db/migrations/00013_inbound_hooks.sql @@ -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; diff --git a/apps/backend/db/migrations/00014_email_log.sql b/apps/backend/db/migrations/00014_email_log.sql new file mode 100644 index 0000000..fcf22ad --- /dev/null +++ b/apps/backend/db/migrations/00014_email_log.sql @@ -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; diff --git a/apps/backend/db/migrations/00015_functions.sql b/apps/backend/db/migrations/00015_functions.sql new file mode 100644 index 0000000..9694d38 --- /dev/null +++ b/apps/backend/db/migrations/00015_functions.sql @@ -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; diff --git a/apps/backend/db/migrations/00016_function_triggers.sql b/apps/backend/db/migrations/00016_function_triggers.sql new file mode 100644 index 0000000..55c4964 --- /dev/null +++ b/apps/backend/db/migrations/00016_function_triggers.sql @@ -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; diff --git a/apps/backend/db/queries/alert_rules.sql b/apps/backend/db/queries/alert_rules.sql new file mode 100644 index 0000000..3e46d70 --- /dev/null +++ b/apps/backend/db/queries/alert_rules.sql @@ -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; diff --git a/apps/backend/db/queries/email_log.sql b/apps/backend/db/queries/email_log.sql new file mode 100644 index 0000000..d1c2d50 --- /dev/null +++ b/apps/backend/db/queries/email_log.sql @@ -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; diff --git a/apps/backend/db/queries/functions.sql b/apps/backend/db/queries/functions.sql new file mode 100644 index 0000000..1fd16e7 --- /dev/null +++ b/apps/backend/db/queries/functions.sql @@ -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 = '*'); diff --git a/apps/backend/db/queries/inbound_hooks.sql b/apps/backend/db/queries/inbound_hooks.sql new file mode 100644 index 0000000..fd86243 --- /dev/null +++ b/apps/backend/db/queries/inbound_hooks.sql @@ -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; diff --git a/apps/backend/db/queries/jobs.sql b/apps/backend/db/queries/jobs.sql index 98391c7..90115b1 100644 --- a/apps/backend/db/queries/jobs.sql +++ b/apps/backend/db/queries/jobs.sql @@ -17,8 +17,9 @@ INSERT INTO core.scheduled_jobs ( payload, enabled, next_run_at, - created_by_user_id -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + created_by_user_id, + function_id +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *; -- name: UpdateScheduledJob :one @@ -30,6 +31,7 @@ SET name = $3, next_run_at = $7, secret = CASE WHEN $8::boolean THEN $9 ELSE secret END, payload = CASE WHEN $10::boolean THEN $11 ELSE payload END, + function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END, updated_at = NOW() WHERE id = $1 AND project_id = $2 diff --git a/apps/backend/db/queries/organizations.sql b/apps/backend/db/queries/organizations.sql index 775b269..5ccf891 100644 --- a/apps/backend/db/queries/organizations.sql +++ b/apps/backend/db/queries/organizations.sql @@ -147,3 +147,7 @@ WHERE p.organization_id = $1; DELETE FROM core.organizations WHERE id = $1 RETURNING *; + +-- name: GetOrganizationByID :one +SELECT * FROM core.organizations +WHERE id = $1; diff --git a/apps/backend/db/queries/webhooks.sql b/apps/backend/db/queries/webhooks.sql index daa8a7c..8926eeb 100644 --- a/apps/backend/db/queries/webhooks.sql +++ b/apps/backend/db/queries/webhooks.sql @@ -11,7 +11,10 @@ WHERE id = $1; SELECT * FROM core.webhooks WHERE project_id = $1 AND enabled - AND (cardinality(events) = 0 OR $2::text = ANY(events)); + AND (cardinality(events) = 0 OR EXISTS ( + SELECT 1 FROM unnest(events) AS e + WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%') + )); -- name: CreateWebhook :one INSERT INTO core.webhooks ( diff --git a/apps/backend/internal/app/app.go b/apps/backend/internal/app/app.go index f118103..a6b99cb 100644 --- a/apps/backend/internal/app/app.go +++ b/apps/backend/internal/app/app.go @@ -81,9 +81,31 @@ func Bootstrap(ctx context.Context) (*App, error) { } } - store, err := storage.NewLocalStore(cfg.StorageRoot) - if err != nil { - return nil, err + var store storage.Store + if cfg.StorageDriver == "s3" { + s3Store, err := storage.NewS3Store(storage.S3Config{ + Endpoint: cfg.S3Endpoint, + Region: cfg.S3Region, + Bucket: cfg.S3Bucket, + AccessKeyID: cfg.S3AccessKeyID, + SecretAccessKey: cfg.S3SecretAccessKey, + PublicEndpoint: cfg.S3PublicEndpoint, + Prefix: cfg.S3Prefix, + PathStyle: cfg.S3PathStyle, + }) + if err != nil { + return nil, fmt.Errorf("s3 storage: %w", err) + } + if err := s3Store.EnsureBucket(context.Background()); err != nil { + return nil, err + } + store = s3Store + } else { + localStore, err := storage.NewLocalStore(cfg.StorageRoot) + if err != nil { + return nil, err + } + store = localStore } dbxClient := dbx.NewClient(logger, cfg.DBXDataDir) repo := repositories.NewCoreRepository(dbPool) @@ -91,7 +113,13 @@ func Bootstrap(ctx context.Context) (*App, error) { if err != nil { return nil, fmt.Errorf("encryption key: %w", err) } - platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger) + var fnRunner services.FunctionRunner + if cfg.FunctionsDriver == "docker" { + fnRunner = services.NewDockerRunner(cfg.FunctionsTimeout, cfg.FunctionsMaxBytes) + } else { + fnRunner = services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes) + } + platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, fnRunner) settings := services.NewSettingsService(repo, encryptor) if cfg.Env == "production" { diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go index 972f26c..231fa0f 100644 --- a/apps/backend/internal/config/config.go +++ b/apps/backend/internal/config/config.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strconv" "strings" + "time" ) type Config struct { @@ -35,6 +36,26 @@ type Config struct { SMTPSecure bool EncryptionKey string + + // Object storage: "local" (BACKEND_STORAGE_ROOT) or "s3". + StorageDriver string + S3Endpoint string + S3Region string + S3Bucket string + S3AccessKeyID string + S3SecretAccessKey string + S3PublicEndpoint string // presigned-URL host when it differs from Endpoint + S3Prefix string + S3PathStyle bool + + // Functions runtime: driver "exec" (runtime binary on the backend host) + // or "docker" (ephemeral locked-down container), binary name/path for + // exec ("auto" resolves per-function runtime from PATH), timeout and + // output cap. + FunctionsDriver string + FunctionsRuntime string + FunctionsTimeout time.Duration + FunctionsMaxBytes int64 } // devEncryptionKey is the built-in credential key for local development only. @@ -63,6 +84,17 @@ func Load() (Config, error) { SMTPUser: os.Getenv("SMTP_USER"), SMTPPassword: os.Getenv("SMTP_PASSWORD"), EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"), + StorageDriver: getenv("BACKEND_STORAGE_DRIVER", "local"), + S3Endpoint: os.Getenv("S3_ENDPOINT"), + S3Region: getenv("S3_REGION", "us-east-1"), + S3Bucket: os.Getenv("S3_BUCKET"), + S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), + S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"), + S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"), + S3Prefix: os.Getenv("S3_PREFIX"), + FunctionsDriver: getenv("FUNCTIONS_DRIVER", "exec"), + FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"), + FunctionsMaxBytes: 65536, } smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025")) @@ -95,6 +127,24 @@ func Load() (Config, error) { } cfg.SMTPSecure = smtpSecure + s3PathStyle, err := strconv.ParseBool(getenv("S3_PATH_STYLE", "true")) + if err != nil { + return Config{}, fmt.Errorf("parse S3_PATH_STYLE: %w", err) + } + cfg.S3PathStyle = s3PathStyle + + fnTimeout, err := strconv.Atoi(getenv("FUNCTIONS_TIMEOUT_SECONDS", "30")) + if err != nil { + return Config{}, fmt.Errorf("parse FUNCTIONS_TIMEOUT_SECONDS: %w", err) + } + cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second + + switch cfg.FunctionsDriver { + case "exec", "docker": + default: + return Config{}, fmt.Errorf("FUNCTIONS_DRIVER %q must be exec or docker", cfg.FunctionsDriver) + } + var missing []string if cfg.DatabaseURL == "" { missing = append(missing, "DATABASE_URL") @@ -102,8 +152,23 @@ func Load() (Config, error) { if cfg.JWTSecret == "" { missing = append(missing, "JWT_SECRET") } - if cfg.StorageRoot == "" { - missing = append(missing, "BACKEND_STORAGE_ROOT") + switch cfg.StorageDriver { + case "local": + if cfg.StorageRoot == "" { + missing = append(missing, "BACKEND_STORAGE_ROOT") + } + case "s3": + if cfg.S3Bucket == "" { + missing = append(missing, "S3_BUCKET") + } + if cfg.S3AccessKeyID == "" { + missing = append(missing, "S3_ACCESS_KEY_ID") + } + if cfg.S3SecretAccessKey == "" { + missing = append(missing, "S3_SECRET_ACCESS_KEY") + } + default: + return Config{}, fmt.Errorf("unknown BACKEND_STORAGE_DRIVER %q (want local or s3)", cfg.StorageDriver) } if cfg.AuthInternalBaseURL == "" { missing = append(missing, "AUTH_INTERNAL_BASE_URL") diff --git a/apps/backend/internal/database/db/alert_rules.sql.go b/apps/backend/internal/database/db/alert_rules.sql.go new file mode 100644 index 0000000..5b1d4ca --- /dev/null +++ b/apps/backend/internal/database/db/alert_rules.sql.go @@ -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 +} diff --git a/apps/backend/internal/database/db/email_log.sql.go b/apps/backend/internal/database/db/email_log.sql.go new file mode 100644 index 0000000..6ec05dd --- /dev/null +++ b/apps/backend/internal/database/db/email_log.sql.go @@ -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 +} diff --git a/apps/backend/internal/database/db/functions.sql.go b/apps/backend/internal/database/db/functions.sql.go new file mode 100644 index 0000000..027c43d --- /dev/null +++ b/apps/backend/internal/database/db/functions.sql.go @@ -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 +} diff --git a/apps/backend/internal/database/db/inbound_hooks.sql.go b/apps/backend/internal/database/db/inbound_hooks.sql.go new file mode 100644 index 0000000..589bd16 --- /dev/null +++ b/apps/backend/internal/database/db/inbound_hooks.sql.go @@ -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 +} diff --git a/apps/backend/internal/database/db/jobs.sql.go b/apps/backend/internal/database/db/jobs.sql.go index 299c86c..a800923 100644 --- a/apps/backend/internal/database/db/jobs.sql.go +++ b/apps/backend/internal/database/db/jobs.sql.go @@ -22,9 +22,10 @@ INSERT INTO core.scheduled_jobs ( payload, enabled, next_run_at, - created_by_user_id -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) -RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at + created_by_user_id, + function_id +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id ` type CreateScheduledJobParams struct { @@ -37,6 +38,7 @@ type CreateScheduledJobParams struct { Enabled bool `json:"enabled"` NextRunAt pgtype.Timestamptz `json:"next_run_at"` CreatedByUserID pgtype.UUID `json:"created_by_user_id"` + FunctionID pgtype.UUID `json:"function_id"` } func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) { @@ -50,6 +52,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob arg.Enabled, arg.NextRunAt, arg.CreatedByUserID, + arg.FunctionID, ) var i CoreScheduledJob err := row.Scan( @@ -67,6 +70,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob &i.CreatedByUserID, &i.CreatedAt, &i.UpdatedAt, + &i.FunctionID, ) return i, err } @@ -133,7 +137,7 @@ func (q *Queries) FinishScheduledJobRun(ctx context.Context, arg FinishScheduled } 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 ` @@ -155,6 +159,7 @@ func (q *Queries) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreSc &i.CreatedByUserID, &i.CreatedAt, &i.UpdatedAt, + &i.FunctionID, ) return i, err } @@ -188,7 +193,7 @@ func (q *Queries) InsertScheduledJobRun(ctx context.Context, arg InsertScheduled } 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 AND next_run_at IS NOT NULL AND next_run_at <= $1 @@ -218,6 +223,7 @@ func (q *Queries) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Tim &i.CreatedByUserID, &i.CreatedAt, &i.UpdatedAt, + &i.FunctionID, ); err != nil { return nil, err } @@ -272,7 +278,7 @@ func (q *Queries) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJob } 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 ORDER BY created_at ASC ` @@ -301,6 +307,7 @@ func (q *Queries) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([ &i.CreatedByUserID, &i.CreatedAt, &i.UpdatedAt, + &i.FunctionID, ); err != nil { return nil, err } @@ -319,7 +326,7 @@ SET last_run_at = $2, next_run_at = $4, updated_at = NOW() WHERE id = $1 -RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at +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 { @@ -352,6 +359,7 @@ func (q *Queries) MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobR &i.CreatedByUserID, &i.CreatedAt, &i.UpdatedAt, + &i.FunctionID, ) return i, err } @@ -386,24 +394,27 @@ SET name = $3, next_run_at = $7, secret = CASE WHEN $8::boolean THEN $9 ELSE secret END, payload = CASE WHEN $10::boolean THEN $11 ELSE payload END, + function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END, updated_at = NOW() WHERE id = $1 AND project_id = $2 -RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at +RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id ` type UpdateScheduledJobParams struct { - ID uuid.UUID `json:"id"` - ProjectID uuid.UUID `json:"project_id"` - Name string `json:"name"` - Schedule string `json:"schedule"` - Url string `json:"url"` - Enabled bool `json:"enabled"` - NextRunAt pgtype.Timestamptz `json:"next_run_at"` - Column8 bool `json:"column_8"` - Secret []byte `json:"secret"` - Column10 bool `json:"column_10"` - Payload []byte `json:"payload"` + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Schedule string `json:"schedule"` + Url string `json:"url"` + Enabled bool `json:"enabled"` + NextRunAt pgtype.Timestamptz `json:"next_run_at"` + Column8 bool `json:"column_8"` + Secret []byte `json:"secret"` + Column10 bool `json:"column_10"` + Payload []byte `json:"payload"` + Column12 bool `json:"column_12"` + FunctionID pgtype.UUID `json:"function_id"` } func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) { @@ -419,6 +430,8 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob arg.Secret, arg.Column10, arg.Payload, + arg.Column12, + arg.FunctionID, ) var i CoreScheduledJob err := row.Scan( @@ -436,6 +449,7 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob &i.CreatedByUserID, &i.CreatedAt, &i.UpdatedAt, + &i.FunctionID, ) return i, err } diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index 9f97578..d353de4 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -140,6 +140,18 @@ func (ns NullCoreProjectRole) Value() (driver.Value, error) { return string(ns.CoreProjectRole), nil } +type CoreAlertRule struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Kind string `json:"kind"` + Config []byte `json:"config"` + Enabled bool `json:"enabled"` + State []byte `json:"state"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type CoreApiKey struct { ID uuid.UUID `json:"id"` ProjectID uuid.UUID `json:"project_id"` @@ -231,6 +243,17 @@ type CoreDocument struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type CoreEmailLog struct { + ID uuid.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Template string `json:"template"` + ToEmail string `json:"to_email"` + Subject string `json:"subject"` + Status string `json:"status"` + Error string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type CoreEvent struct { ID int64 `json:"id"` ProjectID uuid.UUID `json:"project_id"` @@ -243,6 +266,45 @@ type CoreEvent struct { Ts pgtype.Timestamptz `json:"ts"` } +type CoreFunction struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Code string `json:"code"` + Runtime string `json:"runtime"` + Enabled bool `json:"enabled"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EventPattern string `json:"event_pattern"` +} + +type CoreFunctionRun struct { + ID uuid.UUID `json:"id"` + FunctionID uuid.UUID `json:"function_id"` + Trigger string `json:"trigger"` + Status string `json:"status"` + ExitCode *int32 `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + DurationMs int32 `json:"duration_ms"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CoreInboundHook struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Token string `json:"token"` + Mode string `json:"mode"` + JobID pgtype.UUID `json:"job_id"` + Secret []byte `json:"secret"` + Enabled bool `json:"enabled"` + LastReceivedAt pgtype.Timestamptz `json:"last_received_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + FunctionID pgtype.UUID `json:"function_id"` +} + type CoreIntegration struct { ID uuid.UUID `json:"id"` ProjectID uuid.UUID `json:"project_id"` @@ -333,6 +395,7 @@ type CoreScheduledJob struct { CreatedByUserID pgtype.UUID `json:"created_by_user_id"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + FunctionID pgtype.UUID `json:"function_id"` } type CoreScheduledJobRun struct { diff --git a/apps/backend/internal/database/db/organizations.sql.go b/apps/backend/internal/database/db/organizations.sql.go index 2986f91..9f912de 100644 --- a/apps/backend/internal/database/db/organizations.sql.go +++ b/apps/backend/internal/database/db/organizations.sql.go @@ -241,6 +241,23 @@ func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string return i, err } +const getOrganizationByID = `-- name: GetOrganizationByID :one +SELECT id, slug, name, created_at FROM core.organizations +WHERE id = $1 +` + +func (q *Queries) GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) { + row := q.db.QueryRow(ctx, getOrganizationByID, id) + var i CoreOrganization + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.CreatedAt, + ) + return i, err +} + const getOrganizationMembership = `-- name: GetOrganizationMembership :one SELECT om.id, om.organization_id, om.user_id, om.role, om.created_at, diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index 902086a..c5e4f1a 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -23,24 +23,30 @@ type Querier interface { CountOrganizations(ctx context.Context) (int64, error) CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error) CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error) + CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error) CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error) CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error) CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error) CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error) CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error) + CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error) + CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error) CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error) CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error) CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error) + DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error) DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error) DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error DeleteComponent(ctx context.Context, arg DeleteComponentParams) error DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error) DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error + DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error + DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error) DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) @@ -54,6 +60,7 @@ type Querier interface { FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error) GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error) GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error) + GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error) GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error) GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error) @@ -62,9 +69,14 @@ type Querier interface { GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error) GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error) GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error) + GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error) + GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error) + GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error) + GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error) GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error) GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error) + GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error) GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error) @@ -77,10 +89,15 @@ type Querier interface { GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error) GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error) HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error) + InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error) + InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error) InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error) InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error) + // Per-component most recent heartbeat — heartbeat_silence detection. + LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error) + ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error) ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error) ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error) @@ -90,8 +107,14 @@ type Querier interface { ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error) ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error) + ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) + ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error) ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error) + ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error) + ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error) + ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error) + ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error) ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error) ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error) ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error) @@ -114,10 +137,13 @@ type Querier interface { MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error) MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error) PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error + // Per-component error count inside a sliding window — error_spike detection. + RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error) RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error) RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error) + SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error SweepExpiredAuditLogs(ctx context.Context) (int64, error) // Per-project retention sweeps. A retention of 0 days disables the sweep for // that stream. Pending webhook deliveries are never swept — they may still be @@ -128,9 +154,12 @@ type Querier interface { SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error) SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error) TouchAPIKey(ctx context.Context, id uuid.UUID) error + TouchInboundHook(ctx context.Context, id uuid.UUID) error + UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error) + UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error) UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error) UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error) diff --git a/apps/backend/internal/database/db/webhooks.sql.go b/apps/backend/internal/database/db/webhooks.sql.go index bf1eaa4..f39a6ff 100644 --- a/apps/backend/internal/database/db/webhooks.sql.go +++ b/apps/backend/internal/database/db/webhooks.sql.go @@ -291,7 +291,10 @@ const listWebhooksForEvent = `-- name: ListWebhooksForEvent :many SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks WHERE project_id = $1 AND enabled - AND (cardinality(events) = 0 OR $2::text = ANY(events)) + AND (cardinality(events) = 0 OR EXISTS ( + SELECT 1 FROM unnest(events) AS e + WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%') + )) ` type ListWebhooksForEventParams struct { diff --git a/apps/backend/internal/handlers/alerts.go b/apps/backend/internal/handlers/alerts.go new file mode 100644 index 0000000..d0ee024 --- /dev/null +++ b/apps/backend/internal/handlers/alerts.go @@ -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) +} diff --git a/apps/backend/internal/handlers/events.go b/apps/backend/internal/handlers/events.go new file mode 100644 index 0000000..359e8d0 --- /dev/null +++ b/apps/backend/internal/handlers/events.go @@ -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}) +} diff --git a/apps/backend/internal/handlers/functions.go b/apps/backend/internal/handlers/functions.go new file mode 100644 index 0000000..3d60e45 --- /dev/null +++ b/apps/backend/internal/handlers/functions.go @@ -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}) +} diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index 672aa00..48132e9 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -2,6 +2,7 @@ package handlers import ( "errors" + "fmt" "io" "net/http" "strconv" @@ -11,6 +12,7 @@ import ( "github.com/go-playground/validator/v10" "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" db "github.com/tdvorak/primora/apps/backend/internal/database/db" "github.com/tdvorak/primora/apps/backend/internal/middleware" @@ -35,6 +37,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api := router.Group("/api/v1") api.GET("/me", h.me) + api.GET("/context", h.actorContext) api.GET("/instance/public", h.instancePublic) api.GET("/instance/settings", h.listInstanceSettings) api.PUT("/instance/settings/:key", h.updateInstanceSetting) @@ -79,7 +82,20 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument) api.OPTIONS("/ingest", h.ingestOptions) api.POST("/ingest", h.ingest) + api.POST("/hooks/:token", h.receiveInboundHook) + api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks) + api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook) + api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook) + api.GET("/projects/:projectID/emails", h.listEmailLog) + api.GET("/projects/:projectID/functions", h.listFunctions) + api.POST("/projects/:projectID/functions", h.createFunction) + api.GET("/projects/:projectID/functions/:functionID", h.getFunction) + api.PUT("/projects/:projectID/functions/:functionID", h.updateFunction) + api.DELETE("/projects/:projectID/functions/:functionID", h.deleteFunction) + api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction) + api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns) api.GET("/projects/:projectID/events", h.listEvents) + api.POST("/projects/:projectID/events", h.publishEvent) api.GET("/projects/:projectID/issues", h.listIssues) api.GET("/projects/:projectID/components", h.listComponents) api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent) @@ -121,10 +137,17 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret) api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret) api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret) + + api.GET("/projects/:projectID/alerts", h.listAlertRules) + api.POST("/projects/:projectID/alerts", h.createAlertRule) + api.PUT("/projects/:projectID/alerts/:ruleID", h.updateAlertRule) + api.DELETE("/projects/:projectID/alerts/:ruleID", h.deleteAlertRule) api.GET("/projects/:projectID/realtime/stream", h.realtimeStream) + api.GET("/projects/:projectID/realtime/presence", h.realtimePresence) api.GET("/buckets/:bucketID/objects", h.listObjects) api.POST("/buckets/:bucketID/objects", h.uploadObject) api.POST("/buckets/:bucketID/object-copies", h.copyObject) + api.POST("/buckets/:bucketID/object-presigns", h.presignObject) api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject) api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject) api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject) @@ -161,6 +184,19 @@ func (h *HTTPHandler) me(c *gin.Context) { c.JSON(http.StatusOK, result) } +func (h *HTTPHandler) actorContext(c *gin.Context) { + actor, ok := middleware.RequireActor(c) + if !ok { + return + } + result, err := h.Platform.ActorContext(c.Request.Context(), actor) + if err != nil { + h.handleError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + func (h *HTTPHandler) bootstrap(c *gin.Context) { actor, ok := middleware.RequireActor(c) if !ok { @@ -874,6 +910,43 @@ func (h *HTTPHandler) updateObject(c *gin.Context) { c.Status(http.StatusNoContent) } +type presignObjectRequest struct { + Key string `json:"key" validate:"required"` + Op string `json:"op" validate:"omitempty,oneof=download upload"` + TTLSeconds int `json:"ttl_seconds" validate:"omitempty,min=60,max=3600"` +} + +// presignObject returns a short-lived URL clients call against storage +// directly — keeps large transfers off the backend. S3 driver only. +func (h *HTTPHandler) presignObject(c *gin.Context) { + actor, ok := middleware.RequireActor(c) + if !ok { + return + } + bucketID, ok := parseUUIDParam(c, "bucketID") + if !ok { + return + } + var body presignObjectRequest + if !h.bindAndValidate(c, &body) { + return + } + out, err := h.Platform.PresignObject(c.Request.Context(), actor, bucketID, strings.TrimSpace(body.Key), body.Op, body.TTLSeconds) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found") + return + } + if errors.Is(err, services.ErrPresignUnsupported) { + apperrors.Abort(c, http.StatusBadRequest, "presign_unsupported", err.Error()) + return + } + h.handleError(c, err) + return + } + c.JSON(http.StatusOK, out) +} + func (h *HTTPHandler) deleteObject(c *gin.Context) { actor, ok := middleware.RequireActor(c) if !ok { @@ -1207,17 +1280,58 @@ func (h *HTTPHandler) bindAndValidate(c *gin.Context, target any) bool { return false } if err := h.Validate.Struct(target); err != nil { - apperrors.Abort(c, http.StatusBadRequest, "validation_failed", err.Error()) + var valErrs validator.ValidationErrors + if errors.As(err, &valErrs) && len(valErrs) > 0 { + apperrors.Abort(c, http.StatusBadRequest, "validation_failed", formatFieldError(valErrs[0])) + } else { + apperrors.Abort(c, http.StatusBadRequest, "validation_failed", "invalid request body") + } return false } return true } +func formatFieldError(f validator.FieldError) string { + if f.Tag() == "required" { + return fmt.Sprintf("%s is required", strings.ToLower(f.Field())) + } + return fmt.Sprintf("%s failed %s validation", strings.ToLower(f.Field()), f.Tag()) +} + func (h *HTTPHandler) handleError(c *gin.Context, err error) { switch { case errors.Is(err, io.EOF): apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty") + case errors.Is(err, pgx.ErrNoRows): + apperrors.Abort(c, http.StatusNotFound, "not_found", "resource not found") default: + var inputErr *services.InputError + if errors.As(err, &inputErr) { + apperrors.Abort(c, http.StatusBadRequest, "invalid_request", inputErr.Error()) + return + } + var valErrs validator.ValidationErrors + if errors.As(err, &valErrs) && len(valErrs) > 0 { + apperrors.Abort(c, http.StatusBadRequest, "invalid_request", formatFieldError(valErrs[0])) + return + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + // Structured codes — never leak raw SQLSTATE text or constraint names. + switch pgErr.Code { + case "23505": + apperrors.Abort(c, http.StatusConflict, "conflict", "resource already exists") + case "23503": + apperrors.Abort(c, http.StatusConflict, "conflict", "referenced resource does not exist") + case "22001", "54000": + apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "value too large") + case "22021": + apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "input is not valid UTF-8") + default: + apperrors.Abort(c, http.StatusInternalServerError, "request_failed", "database error") + } + return + } status := http.StatusInternalServerError message := strings.ToLower(err.Error()) if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") { diff --git a/apps/backend/internal/handlers/http_test.go b/apps/backend/internal/handlers/http_test.go index 8e44012..7cbc865 100644 --- a/apps/backend/internal/handlers/http_test.go +++ b/apps/backend/internal/handlers/http_test.go @@ -4,9 +4,14 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "github.com/tdvorak/primora/apps/backend/internal/services" ) func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) { @@ -104,4 +109,46 @@ func TestHandleErrorStatusMapping(t *testing.T) { t.Fatalf("unexpected status: %d", recorder.Code) } }) + + t.Run("maps missing rows to not found", func(t *testing.T) { + t.Parallel() + ctx, recorder := newTestContext("") + handler.handleError(ctx, pgx.ErrNoRows) + if recorder.Code != http.StatusNotFound { + t.Fatalf("unexpected status: %d", recorder.Code) + } + }) + + t.Run("maps InputError to bad request with message", func(t *testing.T) { + t.Parallel() + ctx, recorder := newTestContext("") + handler.handleError(ctx, services.NewInputError("webhook url must be https")) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("unexpected status: %d", recorder.Code) + } + if !strings.Contains(recorder.Body.String(), "webhook url must be https") { + t.Fatalf("expected reason in body, got %s", recorder.Body.String()) + } + }) + + t.Run("maps unique violation to conflict without leaking", func(t *testing.T) { + t.Parallel() + ctx, recorder := newTestContext("") + handler.handleError(ctx, &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint \"projects_slug_key\""}) + if recorder.Code != http.StatusConflict { + t.Fatalf("unexpected status: %d", recorder.Code) + } + if strings.Contains(recorder.Body.String(), "projects_slug_key") || strings.Contains(recorder.Body.String(), "23505") { + t.Fatalf("raw constraint leaked: %s", recorder.Body.String()) + } + }) + + t.Run("maps unknown pg error to internal error", func(t *testing.T) { + t.Parallel() + ctx, recorder := newTestContext("") + handler.handleError(ctx, &pgconn.PgError{Code: "XX000", Message: "internal"}) + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("unexpected status: %d", recorder.Code) + } + }) } diff --git a/apps/backend/internal/handlers/inbound.go b/apps/backend/internal/handlers/inbound.go new file mode 100644 index 0000000..bafa9d5 --- /dev/null +++ b/apps/backend/internal/handlers/inbound.go @@ -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) +} diff --git a/apps/backend/internal/services/alerting.go b/apps/backend/internal/services/alerting.go new file mode 100644 index 0000000..01a2327 --- /dev/null +++ b/apps/backend/internal/services/alerting.go @@ -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}) +} diff --git a/apps/backend/internal/services/alerting_test.go b/apps/backend/internal/services/alerting_test.go new file mode 100644 index 0000000..6ab4e18 --- /dev/null +++ b/apps/backend/internal/services/alerting_test.go @@ -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) + } + } +} diff --git a/apps/backend/internal/services/events_test.go b/apps/backend/internal/services/events_test.go new file mode 100644 index 0000000..d480431 --- /dev/null +++ b/apps/backend/internal/services/events_test.go @@ -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) + } + } +} diff --git a/apps/backend/internal/services/function_docker.go b/apps/backend/internal/services/function_docker.go new file mode 100644 index 0000000..83cd030 --- /dev/null +++ b/apps/backend/internal/services/function_docker.go @@ -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 +} diff --git a/apps/backend/internal/services/function_docker_test.go b/apps/backend/internal/services/function_docker_test.go new file mode 100644 index 0000000..dbbd2a0 --- /dev/null +++ b/apps/backend/internal/services/function_docker_test.go @@ -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) + } +} diff --git a/apps/backend/internal/services/functions.go b/apps/backend/internal/services/functions.go new file mode 100644 index 0000000..b0d05b2 --- /dev/null +++ b/apps/backend/internal/services/functions.go @@ -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 +} diff --git a/apps/backend/internal/services/functions_test.go b/apps/backend/internal/services/functions_test.go new file mode 100644 index 0000000..256c453 --- /dev/null +++ b/apps/backend/internal/services/functions_test.go @@ -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) + } + } +} diff --git a/apps/backend/internal/services/inbound.go b/apps/backend/internal/services/inbound.go new file mode 100644 index 0000000..1866d5a --- /dev/null +++ b/apps/backend/internal/services/inbound.go @@ -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/. 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) +} diff --git a/apps/backend/internal/services/integrations_test.go b/apps/backend/internal/services/integrations_test.go index e46276a..72a2b32 100644 --- a/apps/backend/internal/services/integrations_test.go +++ b/apps/backend/internal/services/integrations_test.go @@ -104,4 +104,39 @@ func TestValidateWebhookEvents(t *testing.T) { if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil { t.Error("expected unknown event rejected") } + for _, ok := range [][]string{ + {"*"}, + {"document.*"}, + {"custom.deploy.done"}, + {"custom.*"}, + {"custom.deploy.*"}, + } { + if _, err := validateWebhookEvents(ok); err != nil { + t.Errorf("expected %v accepted, got %v", ok, err) + } + } + for _, bad := range [][]string{ + {"banana.*"}, {"custom..*"}, {"custom.UPPER"}, {"document.exploded"}, + } { + if _, err := validateWebhookEvents(bad); err == nil { + t.Errorf("expected %v rejected", bad) + } + } +} + +func TestPrivateWebhookHostHardening(t *testing.T) { + private := []string{"localhost", "minio", "echo-svc", "host.docker.internal", + "10.0.0.4", "172.16.5.5", "192.168.1.10", "fe80::1", "svc.local", "db.internal"} + public := []string{"169.254.169.254", "fd00:ec2::254", "2130706433", "0x7f000001", + "8.8.8.8", "example.com", "999999999999"} + for _, h := range private { + if !isPrivateWebhookHost(h) { + t.Errorf("expected %q private", h) + } + } + for _, h := range public { + if isPrivateWebhookHost(h) { + t.Errorf("expected %q public/blocked", h) + } + } } diff --git a/apps/backend/internal/services/jobs.go b/apps/backend/internal/services/jobs.go index 8421fd9..82e4e6c 100644 --- a/apps/backend/internal/services/jobs.go +++ b/apps/backend/internal/services/jobs.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" "github.com/robfig/cron/v3" db "github.com/tdvorak/primora/apps/backend/internal/database/db" @@ -34,6 +35,7 @@ type ScheduledJobSummary struct { Name string `json:"name"` Schedule string `json:"schedule"` URL string `json:"url"` + FunctionID *string `json:"function_id,omitempty"` Payload json.RawMessage `json:"payload"` Enabled bool `json:"enabled"` HasSecret bool `json:"has_secret"` @@ -58,21 +60,23 @@ type ScheduledJobRunSummary struct { } type CreateScheduledJobInput struct { - Name string `json:"name" validate:"required,min=2"` - Schedule string `json:"schedule" validate:"required"` - URL string `json:"url" validate:"required,url"` - Secret string `json:"secret"` - Payload map[string]any `json:"payload"` - Enabled *bool `json:"enabled"` + Name string `json:"name" validate:"required,min=2"` + Schedule string `json:"schedule" validate:"required"` + URL string `json:"url" validate:"required_without=FunctionID,omitempty,url"` + FunctionID string `json:"function_id" validate:"omitempty,uuid"` + Secret string `json:"secret"` + Payload map[string]any `json:"payload"` + Enabled *bool `json:"enabled"` } type UpdateScheduledJobInput struct { - Name *string `json:"name" validate:"omitempty,min=2"` - Schedule *string `json:"schedule"` - URL *string `json:"url" validate:"omitempty,url"` - Secret *string `json:"secret"` - Payload map[string]any `json:"payload"` - Enabled *bool `json:"enabled"` + Name *string `json:"name" validate:"omitempty,min=2"` + Schedule *string `json:"schedule"` + URL *string `json:"url" validate:"omitempty,url"` + FunctionID *string `json:"function_id" validate:"omitempty,uuid"` + Secret *string `json:"secret"` + Payload map[string]any `json:"payload"` + Enabled *bool `json:"enabled"` } func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary { @@ -87,6 +91,10 @@ func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary { HasSecret: len(row.Secret) > 0, CreatedAt: row.CreatedAt.Time, } + if row.FunctionID.Valid { + id := uuid.UUID(row.FunctionID.Bytes).String() + out.FunctionID = &id + } if row.LastRunAt.Valid { t := row.LastRunAt.Time out.LastRunAt = &t @@ -175,9 +183,25 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models. if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { return ScheduledJobSummary{}, err } - jobURL, err := normalizeWebhookURL(input.URL) - if err != nil { - return ScheduledJobSummary{}, err + jobURL := "" + var functionID pgtype.UUID + if input.FunctionID != "" { + fid, err := uuid.Parse(input.FunctionID) + if err != nil { + return ScheduledJobSummary{}, fmt.Errorf("invalid function_id") + } + fn, err := s.repo.Queries().GetFunction(ctx, fid) + if err != nil || fn.ProjectID != projectID { + return ScheduledJobSummary{}, fmt.Errorf("function not found in this project") + } + functionID = pgtype.UUID{Bytes: fid, Valid: true} + jobURL = "function://" + fid.String() + } else { + var err error + jobURL, err = normalizeWebhookURL(input.URL) + if err != nil { + return ScheduledJobSummary{}, err + } } sched, err := parseSchedule(input.Schedule) if err != nil { @@ -221,6 +245,7 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models. Enabled: enabled, NextRunAt: next, CreatedByUserID: createdBy, + FunctionID: functionID, }) if err != nil { 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 } } + rotateFunction := input.FunctionID != nil + functionID := row.FunctionID + if rotateFunction { + raw := strings.TrimSpace(*input.FunctionID) + if raw == "" { + functionID = pgtype.UUID{} // explicit clear → back to URL delivery + if input.URL == nil && strings.HasPrefix(jobURL, "function://") { + return ScheduledJobSummary{}, fmt.Errorf("clearing function_id requires a url") + } + } else { + fid, ferr := uuid.Parse(raw) + if ferr != nil { + return ScheduledJobSummary{}, fmt.Errorf("invalid function_id") + } + fn, ferr := s.repo.Queries().GetFunction(ctx, fid) + if ferr != nil || fn.ProjectID != projectID { + return ScheduledJobSummary{}, fmt.Errorf("function not found in this project") + } + functionID = pgtype.UUID{Bytes: fid, Valid: true} + jobURL = "function://" + fid.String() + } + } enabled := row.Enabled if input.Enabled != nil { enabled = *input.Enabled @@ -301,6 +348,8 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models. Secret: secretBytes, Column10: rotatePayload, Payload: payload, + Column12: rotateFunction, + FunctionID: functionID, }) if err != nil { if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") { @@ -406,8 +455,13 @@ type JobScheduler struct { quit chan struct{} wg sync.WaitGroup started sync.Once + leader *leaderState // onEvent fans a finished run out to webhooks and the realtime stream. onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) + // runFn delivers to a function instead of HTTP when job.FunctionID is set. + // The function receives the resolved job payload, not the run envelope. + // Owned by PlatformService — the scheduler stays transport-agnostic. + runFn func(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error) } type jobExec struct { @@ -415,7 +469,7 @@ type jobExec struct { 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 { logger = slog.Default() } @@ -428,6 +482,7 @@ func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, queue: make(chan jobExec, 256), quit: make(chan struct{}), onEvent: onEvent, + leader: newLeaderState(pool, leaderKeyScheduler), } } @@ -452,8 +507,13 @@ func (j *JobScheduler) run(ctx context.Context) { case exec := <-j.queue: j.execute(exec.job, exec.run) case <-ticker.C: - j.scan() + // Only the leader fires scheduled runs — manual/hook enqueues are + // already scoped to whichever replica received the request. + if j.leader.hold(ctx) { + j.scan() + } case <-j.quit: + j.leader.stop(context.Background()) return case <-ctx.Done(): return @@ -551,7 +611,8 @@ 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) { data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload) if err != nil { @@ -567,6 +628,12 @@ func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run "occurred_at": time.Now().UTC().Format(time.RFC3339), "data": data, }) + if job.FunctionID.Valid { + if j.runFn == nil { + return nil, fmt.Errorf("functions runtime not configured") + } + return j.runFn(ctx, job, run, data) + } secret := "" if len(job.Secret) > 0 && j.enc != nil { if plain, err := j.enc.Decrypt(job.Secret); err == nil { diff --git a/apps/backend/internal/services/leader.go b/apps/backend/internal/services/leader.go new file mode 100644 index 0000000..1e268b9 --- /dev/null +++ b/apps/backend/internal/services/leader.go @@ -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 + } +} + + diff --git a/apps/backend/internal/services/mailer.go b/apps/backend/internal/services/mailer.go index 49d940b..7652258 100644 --- a/apps/backend/internal/services/mailer.go +++ b/apps/backend/internal/services/mailer.go @@ -11,6 +11,9 @@ import ( "github.com/tdvorak/primora/apps/backend/internal/config" ) +// Mailer is transport-only: Resend when configured, plain SMTP otherwise. +// Templates render (subject, body) from data; sends are logged to +// core.email_log by the service layer so operators can see what went out. type Mailer struct { cfg config.Config resend *resend.Client @@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer { return &Mailer{cfg: cfg, resend: resendClient} } -func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error { - subject := fmt.Sprintf("You were invited to %s on Primora", organizationName) - text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n" +// mailTemplate renders a subject + plaintext body from template data. +type mailTemplate func(data map[string]string) (subject string, body string) + +var mailTemplates = map[string]mailTemplate{ + "invitation": func(d map[string]string) (string, string) { + subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"]) + body := fmt.Sprintf( + "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n", + d["invite_url"], + ) + return subject, body + }, +} + +func renderMailTemplate(template string, data map[string]string) (string, string, error) { + tpl, ok := mailTemplates[template] + if !ok { + return "", "", fmt.Errorf("unknown email template %q", template) + } + subject, body := tpl(data) + return subject, body, nil +} + +func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error { if m.resend != nil { _, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{ From: m.cfg.MailFrom, diff --git a/apps/backend/internal/services/mailer_test.go b/apps/backend/internal/services/mailer_test.go new file mode 100644 index 0000000..c1912ef --- /dev/null +++ b/apps/backend/internal/services/mailer_test.go @@ -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 "); got != "no-reply@primora.dev" { + t.Fatalf("extractEmail = %q", got) + } + if got := extractEmail("no-reply@primora.dev"); got != "no-reply@primora.dev" { + t.Fatalf("extractEmail bare = %q", got) + } +} diff --git a/apps/backend/internal/services/platform_service.go b/apps/backend/internal/services/platform_service.go index b1273bd..979c700 100644 --- a/apps/backend/internal/services/platform_service.go +++ b/apps/backend/internal/services/platform_service.go @@ -10,9 +10,12 @@ import ( "fmt" "io" "log/slog" + "net/http" "net/url" "path/filepath" + "regexp" "strings" + "sync" "time" "github.com/google/uuid" @@ -29,7 +32,7 @@ import ( type PlatformService struct { repo *repositories.CoreRepository - store *storage.LocalStore + store storage.Store mailer *Mailer publicURL string dbx *dbx.Client @@ -38,6 +41,13 @@ type PlatformService struct { enc *secrets.Encryptor dispatcher *WebhookDispatcher scheduler *JobScheduler + alerts *alertEvaluator + functions FunctionRunner + logger *slog.Logger + + // presenceMu guards presence: realtime subscriber counts per project. + presenceMu sync.Mutex + presence map[uuid.UUID]int } type BootstrapInput struct { @@ -223,17 +233,84 @@ type InvitationSummary struct { 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 { - s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc} +func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *PlatformService { + s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions, logger: logger, presence: map[uuid.UUID]int{}} if enc != nil { s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger) s.dispatcher.Start(context.Background()) - s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent) + s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent, repo.Pool()) + s.scheduler.runFn = s.runJobFunction s.scheduler.Start(context.Background()) + s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool()) + s.alerts.Start(context.Background()) } return s } +// sendTemplatedEmail renders a registered template, sends it, and writes an +// email_log row with the outcome — failed sends are still logged so the +// dashboard shows them. +func (s *PlatformService) sendTemplatedEmail(ctx context.Context, projectID *uuid.UUID, template, to string, data map[string]string) error { + subject, body, err := renderMailTemplate(template, data) + status, errText := "sent", "" + if err == nil { + if sendErr := s.mailer.Send(ctx, to, subject, body); sendErr != nil { + status, errText, err = "failed", sendErr.Error(), sendErr + } + } else { + status, errText = "failed", err.Error() + } + var pid pgtype.UUID + if projectID != nil { + pid = pgtype.UUID{Bytes: *projectID, Valid: true} + } + _, _ = s.repo.Queries().InsertEmailLog(ctx, db.InsertEmailLogParams{ + ProjectID: pid, + Template: template, + ToEmail: to, + Subject: subject, + Status: status, + Error: errText, + }) + return err +} + +type EmailLogSummary struct { + ID string `json:"id"` + Template string `json:"template"` + ToEmail string `json:"to_email"` + Subject string `json:"subject"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func (s *PlatformService) ListEmailLog(ctx context.Context, actor *models.Actor, projectID uuid.UUID, limit int64) ([]EmailLogSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { + return nil, err + } + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.repo.Queries().ListEmailLog(ctx, db.ListEmailLogParams{ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, Limit: int32(limit)}) + if err != nil { + return nil, err + } + out := make([]EmailLogSummary, len(rows)) + for i, r := range rows { + out[i] = EmailLogSummary{ + ID: r.ID.String(), + Template: r.Template, + ToEmail: r.ToEmail, + Subject: r.Subject, + Status: r.Status, + Error: r.Error, + CreatedAt: r.CreatedAt.Time, + } + } + return out, nil +} + // Close stops the webhook dispatcher and job scheduler. Idempotent. func (s *PlatformService) Close() { if s.dispatcher != nil { @@ -242,6 +319,9 @@ func (s *PlatformService) Close() { if s.scheduler != nil { s.scheduler.Stop() } + if s.alerts != nil { + s.alerts.Stop() + } } // publishEvent fans a domain event out to realtime SSE subscribers and to @@ -256,18 +336,118 @@ func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID, }) } 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) { if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { 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) { 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. (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) { @@ -907,7 +1087,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac if err != nil { inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token } - if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil { + var inviteProject *uuid.UUID + if projectUUID.Valid { + id := uuid.UUID(projectUUID.Bytes) + inviteProject = &id + } + if err := s.sendTemplatedEmail(ctx, inviteProject, "invitation", invitation.Email, map[string]string{ + "organization": "Primora", + "invite_url": inviteURL, + }); err != nil { return nil, err } return map[string]any{ @@ -1336,6 +1524,64 @@ func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bu return object, file, nil } +// ErrPresignUnsupported — the configured storage driver can't mint signed +// URLs (only the S3 driver can). Handlers map it to 400, not 500. +var ErrPresignUnsupported = errors.New("presigned URLs require BACKEND_STORAGE_DRIVER=s3") + +// InputError marks a service-layer failure caused by caller input — the +// HTTP layer maps it to 400 instead of 500. +type InputError struct{ msg string } + +func (e *InputError) Error() string { return e.msg } + +func NewInputError(msg string) error { return &InputError{msg: msg} } + +func inputErrorf(format string, args ...any) error { + return &InputError{msg: fmt.Sprintf(format, args...)} +} + +// PresignObject mints a short-lived direct-to-storage URL for a single +// object. Requires the s3 storage driver — the local store has no external +// URL surface to sign against. +func (s *PlatformService) PresignObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, op string, ttlSeconds int) (*storage.PresignedURL, error) { + bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID) + if err != nil { + return nil, err + } + method := "" + switch op { + case "download", "": + method = http.MethodGet + if err := s.requireBucketRead(ctx, actor, bucket); err != nil { + return nil, err + } + if !objectExists(ctx, s, bucketID, objectKey) { + return nil, pgx.ErrNoRows + } + case "upload": + method = http.MethodPut + if err := s.requireBucketWrite(ctx, actor, bucket); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("invalid op %q — download or upload", op) + } + p, ok := s.store.(storage.Presigner) + if !ok { + return nil, ErrPresignUnsupported + } + ttl := time.Duration(ttlSeconds) * time.Second + return p.Presign(ctx, bucketID.String(), objectKey, method, ttl) +} + +func objectExists(ctx context.Context, s *PlatformService, bucketID uuid.UUID, key string) bool { + _, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{ + BucketID: bucketID, + ObjectKey: key, + }) + return err == nil +} + func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) { sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID) if err != nil { diff --git a/apps/backend/internal/services/webhooks.go b/apps/backend/internal/services/webhooks.go index f9f6925..eba0500 100644 --- a/apps/backend/internal/services/webhooks.go +++ b/apps/backend/internal/services/webhooks.go @@ -14,6 +14,7 @@ import ( "net" "net/http" "net/url" + "regexp" "strings" "sync" "time" @@ -31,6 +32,8 @@ const ( WebhookEventIssueCreated = "issue.created" WebhookEventDeployMarker = "deploy.marker" WebhookEventTest = "webhook.test" + WebhookEventAlertFired = "alert.fired" + WebhookEventAlertResolved = "alert.resolved" WebhookEventDocumentCreated = "document.created" WebhookEventDocumentUpdated = "document.updated" WebhookEventDocumentDeleted = "document.deleted" @@ -43,6 +46,9 @@ var webhookEventTypes = map[string]bool{ WebhookEventIssueCreated: true, WebhookEventDeployMarker: true, WebhookEventTest: true, + WebhookEventAlertFired: true, + WebhookEventAlertResolved: true, + WebhookEventInboundReceived: true, WebhookEventJobRun: true, WebhookEventDocumentCreated: true, WebhookEventDocumentUpdated: true, @@ -160,7 +166,7 @@ func normalizeWebhookURL(raw string) (string, error) { trimmed := strings.TrimSpace(raw) u, err := url.Parse(trimmed) 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" { return trimmed, nil @@ -168,17 +174,35 @@ func normalizeWebhookURL(raw string) (string, error) { if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) { 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 { h := strings.ToLower(strings.Trim(host, "[]")) + if metadataAddrs[h] { + return false + } if h == "localhost" || h == "host.docker.internal" || strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") { return true } 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) if ip == nil { @@ -187,16 +211,40 @@ func isPrivateWebhookHost(host string) bool { return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() } +// webhookEventNamespaces are the valid `.*` 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, `*`, `.*` 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) { out := make([]string, 0, len(events)) seen := map[string]bool{} + namespaces := webhookEventNamespaces() for _, e := range events { e = strings.TrimSpace(e) if e == "" { continue } - if !webhookEventTypes[e] { - return nil, fmt.Errorf("unknown event type %q", e) + valid := webhookEventTypes[e] || 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, .*, custom., or *", e) } if !seen[e] { seen[e] = true diff --git a/apps/backend/internal/storage/local.go b/apps/backend/internal/storage/local.go index 249cfe1..7a4a9d9 100644 --- a/apps/backend/internal/storage/local.go +++ b/apps/backend/internal/storage/local.go @@ -65,7 +65,7 @@ func (s *LocalStore) Put(ctx context.Context, bucketID, objectKey string, reader }, 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) if err != nil { return nil, "", err diff --git a/apps/backend/internal/storage/s3.go b/apps/backend/internal/storage/s3.go new file mode 100644 index 0000000..dd301fc --- /dev/null +++ b/apps/backend/internal/storage/s3.go @@ -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)) +} diff --git a/apps/backend/internal/storage/s3_test.go b/apps/backend/internal/storage/s3_test.go new file mode 100644 index 0000000..1f1d0b5 --- /dev/null +++ b/apps/backend/internal/storage/s3_test.go @@ -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(``) + for k := range f.objects { + if strings.HasPrefix(k, prefix) { + fmt.Fprintf(&out, "%s", k) + } + } + out.WriteString(``) + 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) + } +} diff --git a/apps/backend/internal/storage/store.go b/apps/backend/internal/storage/store.go new file mode 100644 index 0000000..e8f7e69 --- /dev/null +++ b/apps/backend/internal/storage/store.go @@ -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) +} diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index d7b0629..8fb69f3 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -68,6 +68,22 @@ paths: $ref: "#/components/schemas/MeResponse" "401": $ref: "#/components/responses/ErrorResponse" + /context: + get: + tags: [Platform] + operationId: getActorContext + summary: Caller scope — self-discovery for API keys, which cannot call /me + security: + - bearerAuth: [] + responses: + "200": + description: Actor type, scopes, and resolved org/project for API keys + content: + application/json: + schema: + $ref: "#/components/schemas/ActorContext" + "401": + $ref: "#/components/responses/ErrorResponse" /instance/public: get: tags: [Instance] @@ -677,6 +693,37 @@ paths: application/json: schema: $ref: "#/components/schemas/BucketObject" + /buckets/{bucketID}/object-presigns: + post: + tags: [Storage] + operationId: presignBucketObject + description: >- + Mint a short-lived URL the client calls against object storage + directly — keeps large transfers off the backend. Requires + BACKEND_STORAGE_DRIVER=s3; the URL's host comes from + S3_PUBLIC_ENDPOINT (or S3_ENDPOINT when unset). Signatures bind the + host — a presigned URL is valid only at the endpoint it was minted + for. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/BucketID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PresignObjectRequest" + responses: + "200": + description: Presigned URL + content: + application/json: + schema: + $ref: "#/components/schemas/PresignedURL" + "404": + description: Bucket or object not found (download) /buckets/{bucketID}: patch: tags: [Storage] @@ -1300,6 +1347,37 @@ paths: $ref: "#/components/schemas/TelemetryEventListResponse" "401": $ref: "#/components/responses/ErrorResponse" + post: + tags: [Automation] + operationId: publishProjectEvent + summary: Publishes a client-authored domain event. Type must be in the custom.* namespace; the event fans out to realtime subscribers, matching webhooks, and event_pattern functions. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishEventRequest" + responses: + "202": + description: Event accepted for fan-out + content: + application/json: + schema: + type: object + properties: + published: + type: string + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + "403": + $ref: "#/components/responses/ErrorResponse" /projects/{projectID}/issues: get: tags: [Telemetry] @@ -1856,6 +1934,28 @@ paths: description: text/event-stream of {type, occurred_at, data} messages "401": $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/realtime/presence: + get: + tags: [Automation] + operationId: getRealtimePresence + summary: Count of clients currently subscribed to the project's realtime stream. The stream also emits presence.update events on every join/leave. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + responses: + "200": + description: Online subscriber count + content: + application/json: + schema: + type: object + properties: + online: + type: integer + "401": + $ref: "#/components/responses/ErrorResponse" /projects/{projectID}/secrets: get: tags: [Secrets] @@ -1941,6 +2041,408 @@ paths: $ref: "#/components/responses/ErrorResponse" "404": $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/alerts: + get: + tags: [Alerts] + operationId: listAlertRules + summary: Lists telemetry alert rules for the project. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + responses: + "200": + description: Alert rules + content: + application/json: + schema: + $ref: "#/components/schemas/AlertRuleListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + post: + tags: [Alerts] + operationId: createAlertRule + summary: Creates an alert rule. Rules fire `alert.fired`/`alert.resolved` domain events on state transitions — subscribe a webhook to those event types to receive them. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertAlertRuleRequest" + responses: + "201": + description: Alert rule created + content: + application/json: + schema: + $ref: "#/components/schemas/AlertRule" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/alerts/{ruleID}: + put: + tags: [Alerts] + operationId: updateAlertRule + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: ruleID + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertAlertRuleRequest" + responses: + "200": + description: Alert rule updated + content: + application/json: + schema: + $ref: "#/components/schemas/AlertRule" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + delete: + tags: [Alerts] + operationId: deleteAlertRule + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: ruleID + in: path + required: true + schema: + type: string + format: uuid + responses: + "204": + description: Alert rule deleted + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/inbound-hooks: + get: + tags: [Inbound] + operationId: listInboundHooks + summary: Lists inbound webhook endpoints for the project. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + responses: + "200": + description: Inbound hooks + content: + application/json: + schema: + $ref: "#/components/schemas/InboundHookListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + post: + tags: [Inbound] + operationId: createInboundHook + summary: Creates an inbound hook. `event` mode republishes bodies as `inbound.received` domain events; `job` mode triggers a scheduled-job run with the body as its payload. Set `secret` to require `X-Primora-Signature` (HMAC-SHA256 over the raw body). + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateInboundHookRequest" + responses: + "201": + description: Inbound hook created + content: + application/json: + schema: + $ref: "#/components/schemas/InboundHook" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/inbound-hooks/{hookID}: + delete: + tags: [Inbound] + operationId: deleteInboundHook + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: hookID + in: path + required: true + schema: + type: string + format: uuid + responses: + "204": + description: Inbound hook deleted + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + /hooks/{token}: + post: + tags: [Inbound] + operationId: receiveInboundHook + summary: Public ingest endpoint — the URL token is the credential. Body must be JSON (max 256 KiB); sign with X-Primora-Signature when the hook has a secret. + security: [] + parameters: + - name: token + in: path + required: true + schema: + type: string + - name: X-Primora-Signature + in: header + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + responses: + "202": + description: Accepted + content: + application/json: + schema: + $ref: "#/components/schemas/InboundReceiveResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/emails: + get: + tags: [Email] + operationId: listEmailLog + summary: Recent transactional emails sent for this project (invitations etc.) — every send is logged with its outcome. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: limit + in: query + schema: + type: integer + default: 50 + maximum: 200 + responses: + "200": + description: Email log entries + content: + application/json: + schema: + $ref: "#/components/schemas/EmailLogListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/functions: + get: + tags: [Functions] + operationId: listFunctions + summary: List functions in a project. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + responses: + "200": + description: Functions + content: + application/json: + schema: + $ref: "#/components/schemas/FunctionListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + post: + tags: [Functions] + operationId: createFunction + summary: Create a function (name, JS/TS code, runtime bun|deno). + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateFunctionRequest" + responses: + "201": + description: Created function + content: + application/json: + schema: + $ref: "#/components/schemas/Function" + "400": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/functions/{functionID}: + get: + tags: [Functions] + operationId: getFunction + summary: Function metadata plus its source code. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: functionID + in: path + required: true + schema: { type: string, format: uuid } + responses: + "200": + description: Function with code + content: + application/json: + schema: + $ref: "#/components/schemas/FunctionDetailResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + put: + tags: [Functions] + operationId: updateFunction + summary: Update function code and/or enabled state. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: functionID + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateFunctionRequest" + responses: + "200": + description: Updated function + content: + application/json: + schema: + $ref: "#/components/schemas/Function" + "404": + $ref: "#/components/responses/ErrorResponse" + delete: + tags: [Functions] + operationId: deleteFunction + summary: Delete a function and its run history. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: functionID + in: path + required: true + schema: { type: string, format: uuid } + responses: + "204": + description: Deleted + "404": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/functions/{functionID}/invoke: + post: + tags: [Functions] + operationId: invokeFunction + summary: Execute the function with a JSON payload on stdin; returns the recorded run. 502 when the run fails or times out. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: functionID + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/InvokeFunctionRequest" + responses: + "200": + description: Recorded run (status success) + content: + application/json: + schema: + $ref: "#/components/schemas/FunctionRun" + "502": + description: Recorded run (status error|timeout) + content: + application/json: + schema: + $ref: "#/components/schemas/FunctionRun" + "404": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/functions/{functionID}/runs: + get: + tags: [Functions] + operationId: listFunctionRuns + summary: Recent runs for a function. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: functionID + in: path + required: true + schema: { type: string, format: uuid } + - name: limit + in: query + schema: + type: integer + default: 50 + maximum: 200 + responses: + "200": + description: Function runs + content: + application/json: + schema: + $ref: "#/components/schemas/FunctionRunListResponse" + "404": + $ref: "#/components/responses/ErrorResponse" components: securitySchemes: bearerAuth: @@ -2290,6 +2792,34 @@ components: items: $ref: "#/components/schemas/OrganizationSummary" required: [user, organizations] + ActorContext: + type: object + properties: + actor: + type: string + enum: [user, api_key] + scopes: + type: array + items: + type: string + key_prefix: + type: string + organization: + $ref: "#/components/schemas/ContextRef" + project: + $ref: "#/components/schemas/ContextRef" + required: [actor] + ContextRef: + type: object + properties: + id: + type: string + format: uuid + slug: + type: string + name: + type: string + required: [id, slug, name] OrganizationMembership: type: object properties: @@ -2728,6 +3258,48 @@ components: format: uuid nullable: true required: [objectKey, newObjectKey] + PresignObjectRequest: + type: object + properties: + key: + type: string + description: Object key inside the bucket. + op: + type: string + enum: [download, upload] + default: download + description: download → GET URL (object must exist); upload → PUT URL (creates/overwrites). + ttl_seconds: + type: integer + minimum: 60 + maximum: 3600 + default: 900 + required: [key] + PresignedURL: + type: object + properties: + url: + type: string + description: Fully-signed URL — call it with the returned method, no auth header needed. + method: + type: string + enum: [GET, PUT] + expires_at: + type: string + format: date-time + required: [url, method, expires_at] + PublishEventRequest: + type: object + properties: + type: + type: string + pattern: '^custom\.[a-z0-9][a-z0-9_.-]{0,62}$' + description: Event type in the custom.* namespace — realtime subscribers, matching webhooks, and event_pattern functions all fire. + data: + type: object + additionalProperties: true + description: Arbitrary JSON payload carried to subscribers. + required: [type] PaginationMetadata: type: object properties: @@ -3512,7 +4084,7 @@ components: type: array items: type: string - description: Event filter (issue.created, deploy.marker, webhook.test, job.run, document.*, object.*). Empty means all events. + description: Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.*, object.*). Empty means all events. enabled: type: boolean has_secret: @@ -3534,7 +4106,7 @@ components: type: array items: type: string - enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] + enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] enabled: type: boolean required: [url] @@ -3551,7 +4123,7 @@ components: type: array items: type: string - enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] + enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] enabled: type: boolean WebhookCreateResponse: @@ -3656,6 +4228,11 @@ components: description: Cron expression (5-field) or descriptor (@hourly, @every 30m). url: type: string + function_id: + type: string + format: uuid + nullable: true + description: When set, the schedule invokes this function instead of POSTing url. payload: type: object description: Static JSON payload merged into every delivery's data field. @@ -3691,6 +4268,11 @@ components: url: type: string format: uri + description: Delivery target. Required unless function_id is set. + function_id: + type: string + format: uuid + description: Invoke this function on schedule instead of POSTing url. secret: type: string description: HMAC key for X-Primora-Signature. Generated when omitted. @@ -3700,7 +4282,7 @@ components: enabled: type: boolean default: true - required: [name, schedule, url] + required: [name, schedule] UpdateScheduledJobRequest: type: object properties: @@ -3712,6 +4294,11 @@ components: url: type: string format: uri + function_id: + type: string + format: uuid + nullable: true + description: Set to retarget at a function; send "" to clear back to url delivery. secret: type: string description: Replace the signing secret. Omit to keep the current one. @@ -3821,3 +4408,275 @@ components: value: type: string required: [name, value] + AlertRuleConfig: + type: object + properties: + component: + type: string + description: Restrict the rule to one component name; empty = all components. + threshold_minutes: + type: integer + description: heartbeat_silence — fire when a component has not heartbeated for this many minutes (1–10080). + threshold_count: + type: integer + format: int64 + description: error_spike — fire when a component logs at least this many errors inside the window. + window_minutes: + type: integer + description: error_spike — sliding window size (default 15, 1–1440). + AlertRule: + type: object + properties: + id: + type: string + format: uuid + project_id: + type: string + format: uuid + name: + type: string + kind: + type: string + enum: [heartbeat_silence, error_spike] + config: + $ref: "#/components/schemas/AlertRuleConfig" + enabled: + type: boolean + firing: + type: array + items: + type: string + description: Component names currently in breached state. + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: [id, project_id, name, kind, config, enabled, firing, created_at, updated_at] + AlertRuleListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/AlertRule" + required: [items] + UpsertAlertRuleRequest: + type: object + properties: + name: + type: string + description: Env-var-safe name ([A-Za-z_][A-Za-z0-9_]*). + kind: + type: string + enum: [heartbeat_silence, error_spike] + config: + $ref: "#/components/schemas/AlertRuleConfig" + enabled: + type: boolean + required: [name, kind] + InboundHook: + type: object + properties: + id: + type: string + format: uuid + project_id: + type: string + format: uuid + name: + type: string + token: + type: string + url: + type: string + description: Full ingest URL — POST here from the external system. + mode: + type: string + enum: [event, job, function] + job_id: + type: string + function_id: + type: string + has_secret: + type: boolean + enabled: + type: boolean + last_received_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + required: [id, project_id, name, token, url, mode, has_secret, enabled, created_at] + InboundHookListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/InboundHook" + required: [items] + CreateInboundHookRequest: + type: object + properties: + name: + type: string + mode: + type: string + enum: [event, job, function] + default: event + job_id: + type: string + description: Required when mode=job — scheduled job to trigger. + function_id: + type: string + description: Required when mode=function — invoked with the received body. + secret: + type: string + description: Optional — stored encrypted; callers must then sign bodies with X-Primora-Signature. + enabled: + type: boolean + required: [name] + InboundReceiveResponse: + type: object + properties: + received: + type: boolean + event: + type: string + run_id: + type: string + required: [received] + EmailLogEntry: + type: object + properties: + id: + type: string + format: uuid + template: + type: string + to_email: + type: string + subject: + type: string + status: + type: string + enum: [sent, failed] + error: + type: string + created_at: + type: string + format: date-time + required: [id, template, to_email, subject, status, created_at] + EmailLogListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/EmailLogEntry" + required: [items] + Function: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + runtime: + type: string + enum: [bun, deno] + enabled: + type: boolean + event_pattern: + type: string + description: 'Domain-event pattern that triggers this function — "document.*", "*", exact type. Empty = never.' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: [id, name, runtime, enabled, created_at, updated_at] + FunctionListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/Function" + required: [items] + FunctionDetailResponse: + type: object + properties: + function: + $ref: "#/components/schemas/Function" + code: + type: string + required: [function, code] + CreateFunctionRequest: + type: object + properties: + name: + type: string + code: + type: string + runtime: + type: string + enum: [bun, deno] + default: bun + event_pattern: + type: string + description: 'Optional — "document.*", "*", or an exact event type.' + required: [name, code] + UpdateFunctionRequest: + type: object + properties: + code: + type: string + enabled: + type: boolean + event_pattern: + type: string + description: Replace the trigger pattern; "" disables event triggers. + InvokeFunctionRequest: + type: object + properties: + payload: {} + FunctionRun: + type: object + properties: + id: + type: string + format: uuid + trigger: + type: string + enum: [manual, schedule, hook, event] + status: + type: string + enum: [success, error, timeout] + exit_code: + type: integer + nullable: true + stdout: + type: string + stderr: + type: string + duration_ms: + type: integer + created_at: + type: string + format: date-time + required: [id, trigger, status, stdout, stderr, duration_ms, created_at] + FunctionRunListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/FunctionRun" + required: [items] diff --git a/apps/cli/README.md b/apps/cli/README.md index abe9802..fcccea3 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -21,7 +21,14 @@ project interactively. ## Commands `orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`, -`jobs`, `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 \ diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 0e3c37a..8f4cd19 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -3,7 +3,8 @@ import { cac } from "cac"; import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js"; import { cmdAuditList } from "./commands/audit.js"; import { cmdDocumentsList } from "./commands/documents.js"; -import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js"; +import { cmdEventsSend } from "./commands/events.js"; +import { cmdBucketsCreate, cmdBucketsList, cmdBucketsRemove } from "./commands/buckets.js"; import { cmdContext, cmdUse } from "./commands/context.js"; import { cmdInject } from "./commands/inject.js"; import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js"; @@ -12,10 +13,11 @@ import { cmdObjectsDownload, cmdObjectsList, cmdObjectsRemove, + cmdObjectsPresign, cmdObjectsUpload, } from "./commands/objects.js"; -import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js"; -import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js"; +import { cmdOrgsCreate, cmdOrgsList, cmdOrgsRemove } from "./commands/orgs.js"; +import { cmdProjectsCreate, cmdProjectsList, cmdProjectsRemove } from "./commands/projects.js"; import { cmdSecretsGet, cmdSecretsImport, @@ -50,6 +52,7 @@ cli .option("--email ", "Account email") .option("--password ", "Account password") .option("--api-key ", "Store a pk_live_/pk_test_ API key instead of a session") + .option("--json", "Machine-readable output") .action(cmdLogin); cli.command("logout", "Remove stored credentials").action(cmdLogout); @@ -68,6 +71,10 @@ cli .command("orgs:create ", "Create an organization") .option("--slug ", "URL-safe slug (derived from name if omitted)") .action(cmdOrgsCreate); +cli + .command("orgs:rm ", "Delete an organization (id, slug, or name)") + .option("--yes", "Skip the confirmation prompt") + .action(cmdOrgsRemove); cli .command("projects:list", "List projects in the current org") @@ -80,6 +87,10 @@ cli .option("--slug ", "URL-safe slug (derived from name if omitted)") .option("--description ", "Description") .action(cmdProjectsCreate); +cli + .command("projects:rm ", "Delete a project (id, slug, or name)") + .option("--yes", "Skip the confirmation prompt") + .action(cmdProjectsRemove); cli .command("buckets:list", "List buckets in the current project") @@ -92,6 +103,11 @@ cli .option("--slug ", "URL-safe slug (derived from name if omitted)") .option("--public", "Public visibility (default private)") .action(cmdBucketsCreate); +cli + .command("buckets:rm ", "Delete a bucket and its objects (id or slug)") + .option("--project ", "Project override") + .option("--yes", "Skip the confirmation prompt") + .action(cmdBucketsRemove); cli .command("objects:list ", "List objects in a bucket (id or slug)") @@ -114,6 +130,12 @@ cli .command("objects:rm ", "Delete an object") .option("--project ", "Project override") .action(cmdObjectsRemove); +cli + .command("objects:presign ", "Mint a presigned URL (s3 driver)") + .option("--project ", "Project override") + .option("--upload", "PUT URL for direct upload instead of GET download") + .option("--ttl ", "Lifetime 60-3600 (default 900)") + .action(cmdObjectsPresign); cli .command("keys:list", "List API keys for the current project") @@ -167,6 +189,12 @@ cli .option("--offset ", "Page offset") .action(cmdDocumentsList); +cli + .command("events:send ", "Publish a custom.* event to realtime subscribers, webhooks, and functions") + .option("--project ", "Project override") + .option("--data ", "JSON object payload") + .action(cmdEventsSend); + cli .command("audit:list", "List audit events for the current project") .option("--project ", "Project override") @@ -317,9 +345,57 @@ if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) { 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; 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() { try { cli.parse([process.argv[0], process.argv[1], ...argv], { run: false }); + restoreRawOptionStrings(); + const state = cli as unknown as { matchedCommand?: unknown; options: Record }; + if (!state.matchedCommand && !state.options.help && !state.options.version) { + cli.outputHelp(); + if (argv.length) process.exit(1); + return; + } await cli.runMatchedCommand(); } catch (error) { fail(error); diff --git a/apps/cli/src/commands/auth.ts b/apps/cli/src/commands/auth.ts index f0a1424..51160dd 100644 --- a/apps/cli/src/commands/auth.ts +++ b/apps/cli/src/commands/auth.ts @@ -10,6 +10,7 @@ interface LoginOptions { email?: string; password?: string; apiKey?: string; + json?: boolean; } function cancel(): never { @@ -39,9 +40,22 @@ export async function cmdLogin(options: LoginOptions): Promise { throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…"); } 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); - success(`Saved API key credentials to ${configFilePath()}`); - dim("API keys are project-scoped — set context with `primora use --org --project `."); + if (isJson(options)) { + 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; } @@ -94,6 +108,10 @@ export async function cmdLogin(options: LoginOptions): Promise { configureClient(cfg); const me = await PlatformService.getMe(); + if (isJson(options)) { + printJson(me); + return; + } success(`Signed in as ${me.user.email}`); dim(`Config written to ${configFilePath()}`); } @@ -108,6 +126,23 @@ export async function cmdLogout(): Promise { export async function cmdWhoami(options: { json?: boolean }): Promise { const cfg = loadConfig(); 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(); if (isJson(options)) { diff --git a/apps/cli/src/commands/buckets.ts b/apps/cli/src/commands/buckets.ts index 567fe1b..fbe2e38 100644 --- a/apps/cli/src/commands/buckets.ts +++ b/apps/cli/src/commands/buckets.ts @@ -1,8 +1,8 @@ import { CreateBucketRequest, StorageService } from "@primora/api-client"; import { loadConfig } from "../config.js"; -import { configureClient, requireAuth, requireProject } from "../http.js"; -import { isJson, printJson, printTable, success } from "../out.js"; +import { configureClient, requireAuth, requireProject, resolveBucketId } from "../http.js"; +import { confirmOrAbort, isJson, printJson, printTable, success } from "../out.js"; export async function cmdBucketsList(options: { project?: string; @@ -54,3 +54,22 @@ export async function cmdBucketsCreate( } success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`); } + +export async function cmdBucketsRemove( + bucketRef: string, + options: { project?: string; yes?: boolean; json?: boolean }, +): Promise { + 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}`); + } +} diff --git a/apps/cli/src/commands/context.ts b/apps/cli/src/commands/context.ts index cfbd0b0..407442a 100644 --- a/apps/cli/src/commands/context.ts +++ b/apps/cli/src/commands/context.ts @@ -23,6 +23,22 @@ export async function cmdUse(options: UseOptions): Promise { requireAuth(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 orgs = me.organizations; if (orgs.length === 0) { @@ -77,6 +93,19 @@ export async function cmdContext(options: { json?: boolean }): Promise { requireAuth(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 org = me.organizations.find((o) => o.id === cfg.organizationId); const project = org?.projects.find((pr) => pr.id === cfg.projectId); diff --git a/apps/cli/src/commands/events.ts b/apps/cli/src/commands/events.ts new file mode 100644 index 0000000..e7e5f87 --- /dev/null +++ b/apps/cli/src/commands/events.ts @@ -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 { + 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 = {}; + 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.`); +} diff --git a/apps/cli/src/commands/objects.ts b/apps/cli/src/commands/objects.ts index 55b1110..b05593e 100644 --- a/apps/cli/src/commands/objects.ts +++ b/apps/cli/src/commands/objects.ts @@ -2,7 +2,7 @@ import { statSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; import { basename } from "node:path"; -import { StorageService } from "@primora/api-client"; +import { PresignObjectRequest, StorageService } from "@primora/api-client"; import { loadConfig } from "../config.js"; import { @@ -133,3 +133,31 @@ export async function cmdObjectsRemove( await StorageService.deleteBucketObject({ bucketId, objectKey }); success(`Deleted ${objectKey}`); } + +export async function cmdObjectsPresign( + bucketRef: string, + objectKey: string, + options: { project?: string; upload?: boolean; ttl?: string; json?: boolean }, +): Promise { + 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}`); +} diff --git a/apps/cli/src/commands/orgs.ts b/apps/cli/src/commands/orgs.ts index 6b86a83..19f194b 100644 --- a/apps/cli/src/commands/orgs.ts +++ b/apps/cli/src/commands/orgs.ts @@ -38,3 +38,31 @@ export async function cmdOrgsCreate( } success(`Organization "${org.name}" created (${org.slug})`); } + +export async function cmdOrgsRemove( + orgRef: string, + options: { yes?: boolean; json?: boolean }, +): Promise { + 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); +} diff --git a/apps/cli/src/commands/projects.ts b/apps/cli/src/commands/projects.ts index a78cc99..ef79fb8 100644 --- a/apps/cli/src/commands/projects.ts +++ b/apps/cli/src/commands/projects.ts @@ -50,3 +50,34 @@ export async function cmdProjectsCreate( } success(`Project "${project.name}" created (${project.slug})`); } + +export async function cmdProjectsRemove( + projectRef: string, + options: { org?: string; yes?: boolean; json?: boolean }, +): Promise { + 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); +} diff --git a/apps/cli/src/http.ts b/apps/cli/src/http.ts index bc9c53c..6fdd764 100644 --- a/apps/cli/src/http.ts +++ b/apps/cli/src/http.ts @@ -60,6 +60,8 @@ export function configureClient(cfg: CliConfig): void { OpenAPI.BASE = `${cfg.baseUrl}/api/v1`; OpenAPI.HEADERS = undefined; OpenAPI.TOKEN = undefined; + // encodeURI leaves ?#& intact in path params — smuggles query segments. + OpenAPI.ENCODE_PATH = encodeURIComponent; if (cfg.auth?.type === "apiKey") { OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key }; diff --git a/apps/cli/src/out.ts b/apps/cli/src/out.ts index fe77d7e..93b8ee4 100644 --- a/apps/cli/src/out.ts +++ b/apps/cli/src/out.ts @@ -17,9 +17,27 @@ export function dim(message: string): void { 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 { + 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 { - 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"; + // 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 const body = err?.body as { error?: { message?: string } } | undefined; if (body?.error?.message) message = body.error.message; diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 1926479..54c1602 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -44,3 +44,21 @@ file, one crypto implementation — terminal and desktop always agree. Requires Injection and the credential broker stay CLI-only (`primora inject`, `primora agent`). - 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. diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 7ae49ad..274e2db 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "async-broadcast" version = "0.7.2" @@ -506,6 +515,16 @@ dependencies = [ "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]] name = "core-foundation" version = "0.10.1" @@ -529,7 +548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -542,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -713,6 +732,17 @@ dependencies = [ "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]] name = "derive_more" version = "2.1.1" @@ -996,6 +1026,16 @@ dependencies = [ "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]] name = "find-msvc-tools" version = "0.1.12" @@ -1254,6 +1294,17 @@ dependencies = [ "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]] name = "getrandom" version = "0.3.4" @@ -1530,6 +1581,21 @@ dependencies = [ "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]] name = "hyper-util" version = "0.1.20" @@ -1548,9 +1614,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1847,6 +1915,36 @@ dependencies = [ "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]] name = "jni-sys" version = "0.3.1" @@ -2036,6 +2134,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2288,6 +2392,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2303,6 +2408,18 @@ dependencies = [ "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]] name = "objc2-quartz-core" version = "0.3.2" @@ -2366,6 +2483,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2382,6 +2505,20 @@ dependencies = [ "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]] name = "pango" version = "0.18.3" @@ -2615,7 +2752,9 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-process", "tauri-plugin-single-instance", + "tauri-plugin-updater", ] [[package]] @@ -2804,15 +2943,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2824,6 +2968,20 @@ dependencies = [ "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]] name = "rustc-hash" version = "2.1.3" @@ -2852,6 +3010,79 @@ dependencies = [ "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]] name = "rustversion" version = "1.0.23" @@ -2867,6 +3098,15 @@ dependencies = [ "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]] name = "schemars" version = "0.8.22" @@ -2924,6 +3164,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "selectors" version = "0.36.1" @@ -3145,6 +3408,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "siphasher" version = "1.0.3" @@ -3257,6 +3536,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.8" @@ -3320,6 +3605,27 @@ dependencies = [ "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]] name = "system-deps" version = "6.2.2" @@ -3341,7 +3647,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3351,7 +3657,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3384,6 +3690,17 @@ dependencies = [ "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]] name = "target-lexicon" version = "0.12.16" @@ -3408,7 +3725,7 @@ dependencies = [ "heck 0.5.0", "http", "image", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3504,6 +3821,32 @@ dependencies = [ "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]] name = "tauri-plugin-single-instance" version = "2.4.4" @@ -3520,6 +3863,39 @@ dependencies = [ "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]] name = "tauri-runtime" version = "2.11.3" @@ -3530,7 +3906,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3553,7 +3929,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3742,6 +4118,16 @@ dependencies = [ "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]] name = "tokio-util" version = "0.7.19" @@ -4055,6 +4441,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4298,6 +4690,15 @@ dependencies = [ "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]] name = "webview2-com" version = "0.38.2" @@ -4483,6 +4884,17 @@ dependencies = [ "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]] name = "windows-result" version = "0.3.4" @@ -4528,6 +4940,15 @@ dependencies = [ "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]] name = "windows-sys" version = "0.59.0" @@ -4823,7 +5244,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -4870,6 +5291,16 @@ dependencies = [ "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]] name = "yoke" version = "0.8.3" @@ -4984,6 +5415,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" @@ -5017,6 +5454,18 @@ dependencies = [ "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]] name = "zlib-rs" version = "0.6.8" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index f1c0e13..cf5f1d7 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -15,5 +15,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri-plugin-single-instance = "2" +tauri-plugin-updater = "2" +tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 15bd662..7ae9a41 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -3,5 +3,5 @@ "identifier": "default", "description": "Default capabilities for the main window", "windows": ["main", "vault"], - "permissions": ["core:default"] + "permissions": ["core:default", "updater:default", "process:default"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1acab76..b760d09 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -200,6 +200,8 @@ pub fn run() { .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { show_main(app); })) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) .invoke_handler(tauri::generate_handler![ get_server_url, set_server_url, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 06e38ad..ff59db0 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -23,9 +23,18 @@ "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": { "active": true, "targets": "all", + "createUpdaterArtifacts": true, "category": "DeveloperTool", "shortDescription": "Primora desktop client", "longDescription": "Thin desktop shell for a self-hosted Primora deployment. Renders the console served by your own server.", diff --git a/apps/desktop/ui/index.html b/apps/desktop/ui/index.html index 03b5edc..f4e421d 100644 --- a/apps/desktop/ui/index.html +++ b/apps/desktop/ui/index.html @@ -28,6 +28,10 @@

The desktop app renders the console served by your own server. Nothing is hosted by us.

Open the local Secrets Vault

+