Merge pull request #29 from Dvorinka/feat/hardening
CI / Backend Tests (push) Canceled after 0s
CI / Frontend Tests (push) Canceled after 0s
CI / Build All Services (push) Canceled after 0s
CI / Docker Build (auth) (push) Canceled after 0s
CI / Docker Build (backend) (push) Canceled after 0s
CI / Docker Build (frontend) (push) Canceled after 0s
CI / Migration Up/Down (push) Canceled after 0s
CI / Compose E2E Smoke (push) Canceled after 0s
CI / Lint (push) Canceled after 0s
CI / Publish edge images (auth) (push) Canceled after 0s
CI / Publish edge images (backend) (push) Canceled after 0s
CI / Publish edge images (frontend) (push) Canceled after 0s

security: harden agent surface — vault_exec allow-list, SSRF, error hygiene
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-09-24 23:15:24 +02:00
commit dcc676bd52
167 files changed
+10022 -257

No files matched your search

+25
View File
@@ -50,6 +50,31 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1
BACKEND_INTERNAL_URL=http://backend:8080 BACKEND_INTERNAL_URL=http://backend:8080
BACKEND_STORAGE_ROOT=/data/storage BACKEND_STORAGE_ROOT=/data/storage
# Object storage driver: "local" (default, uses BACKEND_STORAGE_ROOT) or "s3".
# For s3, point S3_ENDPOINT at any S3-compatible API (AWS, MinIO, Garage, R2).
BACKEND_STORAGE_DRIVER=local
#S3_ENDPOINT=http://minio:9000
#S3_REGION=us-east-1
#S3_BUCKET=primora-objects
#S3_ACCESS_KEY_ID=
#S3_SECRET_ACCESS_KEY=
#S3_PREFIX=
#S3_PATH_STYLE=true
# Client-facing S3 endpoint for presigned URLs — set when S3_ENDPOINT is an
# internal address (e.g. http://minio:9000 inside compose) that browsers
# can't reach. Presigned URLs are valid only at the host they were minted for.
#S3_PUBLIC_ENDPOINT=https://objects.example.com
# Functions execution. "exec" runs the runtime binary (bun/deno) directly on
# the backend host — simple, but function code inherits backend privileges.
# "docker" runs each invoke in an ephemeral locked-down container (no
# network, read-only fs, 128 MiB mem, non-root) and requires the Docker
# socket mounted into the backend plus the runtime images pulled.
#FUNCTIONS_DRIVER=exec
#FUNCTIONS_RUNTIME=auto
#FUNCTIONS_TIMEOUT_SECONDS=30
#FUNCTIONS_MAX_OUTPUT_BYTES=65536
# Mail defaults — each is overridable in-app (Settings → Instance). In-app # Mail defaults — each is overridable in-app (Settings → Instance). In-app
# values win over these env vars; deleting the in-app value reverts here. # values win over these env vars; deleting the in-app value reverts here.
RESEND_API_KEY= RESEND_API_KEY=
+98
View File
@@ -146,6 +146,11 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Build Tauri bundles - name: Build Tauri bundles
# Updater artifacts are minisign-signed; without the key the build
# still succeeds but emits no .sig files (updater can't verify them).
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run bundle --workspace @primora/desktop run: npm run bundle --workspace @primora/desktop
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
@@ -155,8 +160,11 @@ jobs:
apps/desktop/src-tauri/target/release/bundle/**/*.deb apps/desktop/src-tauri/target/release/bundle/**/*.deb
apps/desktop/src-tauri/target/release/bundle/**/*.rpm apps/desktop/src-tauri/target/release/bundle/**/*.rpm
apps/desktop/src-tauri/target/release/bundle/**/*.dmg apps/desktop/src-tauri/target/release/bundle/**/*.dmg
apps/desktop/src-tauri/target/release/bundle/**/*.app.tar.gz
apps/desktop/src-tauri/target/release/bundle/**/*.msi apps/desktop/src-tauri/target/release/bundle/**/*.msi
apps/desktop/src-tauri/target/release/bundle/**/*.exe apps/desktop/src-tauri/target/release/bundle/**/*.exe
apps/desktop/src-tauri/target/release/bundle/**/*.nsis.zip
apps/desktop/src-tauri/target/release/bundle/**/*.sig
if-no-files-found: error if-no-files-found: error
npm-packages: npm-packages:
@@ -225,6 +233,48 @@ jobs:
flag flag
' CHANGELOG.md > notes.md ' CHANGELOG.md > notes.md
[ -s notes.md ] || echo "Release ${VERSION}" > notes.md [ -s notes.md ] || echo "Release ${VERSION}" > notes.md
# Tauri updater manifest: one platform entry per signed updater
# artifact (AppImage.sig / app.tar.gz.sig / nsis.zip.sig). Missing
# sigs mean TAURI_SIGNING_PRIVATE_KEY isn't set — then there is no
# manifest and no update, which is correct (never ship unsigned).
- name: Build updater manifest (latest.json)
run: |
python3 - <<'PY'
import json, os, glob
base = "https://github.com/Dvorinka/Primora/releases/download/%s" % os.environ["VERSION"]
platforms = {}
for sig in glob.glob("dist-release/**/*.sig", recursive=True):
asset = sig[:-4]
name = os.path.basename(asset)
if name.endswith(".AppImage"):
plat = "linux-x86_64"
elif name.endswith(".app.tar.gz"):
plat = "darwin-aarch64" if "aarch64" in name else "darwin-x86_64"
if "aarch64" not in name and "x86_64" not in name:
plat = "darwin-aarch64" # macos-latest is arm64
elif name.endswith(".nsis.zip"):
plat = "windows-x86_64"
else:
continue
platforms[plat] = {
"signature": open(sig).read().strip(),
"url": f"{base}/{name}",
}
manifest = {
"version": os.environ["VERSION"],
"notes": "See the release notes.",
"pub_date": __import__("datetime").datetime.now(
__import__("datetime").timezone.utc
).strftime("%Y-%m-%dT%H:%M:%SZ"),
"platforms": platforms,
}
if platforms:
with open("dist-release/latest.json", "w") as f:
json.dump(manifest, f, indent=2)
print("latest.json:", ", ".join(platforms))
else:
print("no signed updater artifacts — skipping latest.json")
PY
- name: Create release - name: Create release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -234,6 +284,54 @@ jobs:
mapfile -t files < <(find dist-release -type f) mapfile -t files < <(find dist-release -type f)
[ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; } [ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; }
gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}" gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}"
# The manifest must be fetchable at /releases/latest/download/latest.json
if [ -f dist-release/latest.json ]; then
gh release upload "$VERSION" dist-release/latest.json --clobber
fi
# Software bill of materials — CycloneDX JSON per published image plus
# one for the source tree (npm + Go modules). Attached to the release
# next to the binaries; downstream consumers can verify provenance.
sbom:
name: SBOMs
needs: [verify, release]
if: needs.verify.outputs.needed == 'true'
runs-on: ubuntu-latest
permissions:
packages: read
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: npm-publish:
name: Publish to npm name: Publish to npm
+34 -1
View File
@@ -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 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 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`. - **`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. - **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. - **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. - **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. - **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. - **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`. - **Realtime channel subscriptions** (`@primora/client`) — `createRealtimeClient({ endpoint, key, projectId })` opens the project SSE stream (`/realtime/stream`) via fetch + ReadableStream (EventSource can't send `X-API-Key`). `channel("documents").on("created", cb)` subscribes by event namespace (`document`/`object`/`issue`/`deploy`, `"*"` for all); auto-reconnects with capped exponential backoff; `onStateChange` reports `connecting|open|reconnecting|closed`.
- **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components.
- **Inbound webhooks** (`core.inbound_hooks`, migration 00013) — public ingest endpoints `POST /api/v1/hooks/<token>` (the token is the credential). `event` mode republishes the body as `inbound.received` domain events through the signed dispatcher + realtime; `job` mode triggers a scheduled-job run with the received body as its payload (`triggered_by: "hook"`). Optional `secret` requires `X-Primora-Signature` HMAC-SHA256 — the same scheme outbound deliveries use. Bodies capped at 256 KiB. Managed from Integrations → Webhooks.
- **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection <slug> --filter … --order …`. - **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection <slug> --filter … --order …`.
- **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test.
- **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations.
- **S3-compatible object storage backend** — `BACKEND_STORAGE_DRIVER=s3` swaps the local disk store for any S3-compatible API (AWS, MinIO, Garage, R2) via a stdlib-only SigV4 implementation — no AWS SDK dependency. `S3_ENDPOINT`/`S3_REGION`/`S3_BUCKET`/credentials configure it; `S3_PATH_STYLE` (default on) covers MinIO/Garage, off for AWS virtual-hosted. The configured bucket is created on startup if missing; objects stream with computed SHA-256, `storage_path` records `s3://bucket/key`. Verified live end-to-end (bucket → put → get → copy → list → delete).
- **Functions** (`core.functions` + `core.function_runs`, migration 00015) — project-scoped JS/TS executed by a runtime binary on the backend host (`bun` or `deno`, per function). The JSON payload arrives on stdin and as `PRIMORA_PAYLOAD`; stdout/stderr are captured per run (capped at 64 KiB) with status, exit code and duration — `FUNCTIONS_RUNTIME`/`FUNCTIONS_TIMEOUT_SECONDS`/`FUNCTIONS_MAX_OUTPUT_BYTES` configure the runner. API: CRUD under `/projects/:id/functions`, `POST …/invoke` (502 + recorded run on failure), `GET …/runs`. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Code executes with backend privileges — the documented self-hosted trust model.
- **Presigned object URLs** — `POST /buckets/:bucketID/object-presigns` mints SigV4 query-signed `GET`/`PUT` URLs (60 s–1 h TTL) so clients transfer large objects directly against S3 without proxying through the backend. Read role for downloads (object must exist — no key probing), write role for uploads. `S3_PUBLIC_ENDPOINT` rewrites the client-facing host for internal `S3_ENDPOINT`s; signatures bind the host so URLs are valid only where they were minted. The local driver returns a clear error — a presigned URL needs a real object API.
- **Desktop auto-updater** — `tauri-plugin-updater` wired end to end: minisign keypair, `createUpdaterArtifacts`, `latest.json` manifest built from signed artifacts on every release, and a silent background check on the connect screen offering "Update & restart" (download → verify → install → relaunch). Unsigned artifacts can never install — missing signature means no manifest, not a bypass. CI signs via `TAURI_SIGNING_PRIVATE_KEY` secrets; OS-level signing (notarization/Authenticode) remains open.
- **Release SBOMs** — CycloneDX SBOM for each published GHCR image (backend/auth/frontend) plus one for the source tree, attached to every GitHub Release via `anchore/sbom-action`.
- **Sandboxed function driver** — `FUNCTIONS_DRIVER=docker` runs every invoke in an ephemeral locked-down container: no network, read-only rootfs, 128 MiB memory, 0.5 CPU, 64 pids, non-root, all capabilities dropped. Source is injected via base64 env (no bind mount — works on Docker Desktop and remote daemons); timeout force-removes the container so nothing lingers. Verified live: code runs, network is unreachable inside, and deadline kills land.
- **Client events + presence** — `POST /projects/:id/events` publishes client-authored events (`custom.*` namespace enforced — system types can't be spoofed) through the same fan-out as system events: realtime subscribers, matching webhooks, and `event_pattern` functions all fire. Realtime now tracks presence: `presence.update` {online} broadcasts on every subscribe/unsubscribe (stream-only — never reaches webhooks or functions), `GET …/realtime/presence` reads the count. Client SDK gains `publish()` and `presence()`; CLI gains `events:send`.
- **Function triggers** (migration 00016) — three new ways to run a function, all through the same executor and run log. Scheduled jobs accept `function_id` instead of `url` (run status mirrors the exit code); inbound hooks gain `mode: "function"` — the received body becomes the function's stdin payload, acked immediately with the run recorded async; and `event_pattern` on a function (`document.*`, `*`, or an exact type) fires it detached whenever a matching domain event publishes — `{event, project_id, occurred_at, data}` as payload. Runs record trigger source: `manual`/`schedule`/`hook`/`event`. Dashboard: function event-trigger field, job target picker (URL or function), and inbound-hook function mode.
- **`GET /api/v1/context`** — actor self-discovery: for API keys returns scopes, key prefix, and the resolved organization + project (id/slug/name). Fixes the API-key onboarding dead end: `primora login --api-key` now validates the key against the instance before saving and seeds org/project context automatically; `primora use`, `context`, and `whoami` work under API-key auth via this endpoint.
- **CLI `orgs:rm` / `projects:rm` / `buckets:rm`** — destructive deletes gated behind `--yes` in non-interactive mode, interactive confirm otherwise. `buckets:rm` resolves id/slug/name.
- **Webhook wildcard subscriptions** — `events` accepts `*`, `<namespace>.*`, and `custom.*` patterns, matched at dispatch time via SQL `LIKE` — the same semantics function `event_pattern` already used. Client-published `custom.*` events can now be webhooked.
### Security
- **`primora_vault_exec` rewritten as an allow-list** — the deny-list was bypassed by colon-form commands (`["secrets:get","X"]` as a single token evaded the pair check), exposing every vault secret; `--remote` variants revealed project secrets; and the `secret` param was used verbatim as an env name, allowing `NODE_OPTIONS=--require <file>` host code execution. Now only a fixed read-mostly command set runs (`whoami`, `context`, `orgs:list`, `projects:list`, `buckets:list`, `objects:list`, `documents:list`, `jobs:list`, `jobs:runs`, `audit:list`, `keys:list`, `events:send`), path/file flags and `..` args are rejected, injected env names must match `^[A-Z_][A-Z0-9_]{0,63}$`, and loader-hook names (`NODE_*`, `LD_*`, `DYLD_*`, `PATH`, `BASH*`, `PYTHON*`, `GIT_*`, `SSL_CERT*`, `XDG_*`, …) are blocked. Verified: every prior bypass denied, allowed commands still inject.
- **SSRF hardening on webhook/job URLs** — cloud metadata endpoints (`169.254.169.254`, `fd00:ec2::254`) are excluded from the link-local/private allowance, and pure decimal/hex "bare hostnames" (`2130706433`, `0x7f000001` — non-canonical IP encodings of loopback) are no longer treated as Docker service names.
- **Path params now `encodeURIComponent`** in all generated-client consumers (CLI, MCP, dashboard) — `encodeURI` left `?#&` intact, smuggling query segments through `{name}` params (e.g. `secrets/a?reveal=1`).
- **Strict base64 in `primora_upload_object`** — `Buffer.from(b64)` silently drops invalid characters; `contentBase64` is now validated (whitespace-tolerant) so corrupt input can't silently store garbage bytes.
- **MCP no-creds startup** — a missing config/API key previously threw inside `configureClient`, killing the process mid-handshake (protocol hang, stack on stderr). Startup now degrades: `initialize`/`tools/list` answer normally and every tool call returns the actionable error.
### Fixed
- **Silent CLI dispatch** — unknown commands, a bare `primora`, and group names without a verb exited 0 with no output. Now: unknown input errors with exit 1 (bare groups list their verbs), bare `primora` prints help.
- **Arg-parser numeric coercion** — `cac`/`mri` coerced numeric-looking flag values (`--value "007"` → `7`, `--value ""` → `0`), corrupting secrets and keys. Parsed options are now re-read as raw strings from argv for every non-boolean flag; `--value 007` round-trips intact.
- **`pgx.ErrNoRows` → 404** in the shared error mapper — missing resources returned raw "no rows in result set" 500s across all handlers.
- **Structured error hygiene** — `pgconn.PgError` codes map to clean statuses (23505 → 409 "resource already exists", 23503 → 409, 22001/54000 → 400 "value too large", 22021 → 400) instead of leaking SQLSTATE/constraint names; validator failures report `field is required`/`field failed <tag> validation`; a new `services.InputError` type maps service-layer input failures to 400 (webhook URL/event-type errors were 500s). Presign on the local driver returns `presign_unsupported` 400.
- **`fetch failed` polish** — connection failures report the cause code and suggest `primora context` instead of bare undici errors.
- **`login --json`** — the flag now emits JSON on both the session and API-key paths.
- **Dashboard fonts vendored** — Geist/Geist Mono now ship via `@fontsource` inside the bundle instead of a Google Fonts `<link>` the nginx CSP blocked (`style-src 'self'`). Removes the runtime Google dependency, works airgapped, and eliminates the console CSP violation.
## [0.7.1] - 2026-09-23 ## [0.7.1] - 2026-09-23
+29 -5
View File
@@ -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_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes | | `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes | | `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container | yes | | `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container (local driver) | yes |
| `BACKEND_STORAGE_DRIVER` | `local` (default) or `s3` — S3-compatible object storage | no |
| `S3_ENDPOINT` / `S3_REGION` / `S3_BUCKET` | S3 API endpoint (empty endpoint → AWS `s3.<region>.amazonaws.com`), region, bucket | with `s3` |
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 credentials | with `s3` |
| `S3_PREFIX` / `S3_PATH_STYLE` | optional key prefix; path-style URLs (default `true`, needed by MinIO/Garage — set `false` for AWS virtual-hosted) | no |
| `S3_PUBLIC_ENDPOINT` | client-facing S3 endpoint used for presigned URLs when `S3_ENDPOINT` is internal-only (e.g. `http://minio:9000`); signatures bind the public host | no |
| `FUNCTIONS_DRIVER` | Function execution driver: `exec` (default — runtime binary on the backend host; code runs with backend privileges) or `docker` — each invoke runs in an ephemeral locked-down container (no network, read-only fs, 128 MiB, non-root). Docker mode needs `/var/run/docker.sock` mounted into the backend (see the commented volume in `docker-compose.yml`) and the runtime images pulled (`oven/bun`, `denoland/deno`). Source code is capped at 256 KiB under the docker driver | no |
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` / `FUNCTIONS_MAX_OUTPUT_BYTES` | Functions: runtime binary (`auto` resolves `bun`/`deno` — exec driver only; docker driver uses the matching official image), per-run timeout (default 30 s), captured-output cap (default 64 KiB) | no |
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no | | `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no | | `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
@@ -127,10 +134,27 @@ docker compose up -d # 3. rolling recreate; backend migrate
## Storage constraint ## Storage constraint
Object storage is the **local filesystem** (`backend_storage` volume). There Object storage defaults to the **local filesystem** (`backend_storage`
is no S3-compatible backend yet — the volume must be on durable, backed-up volume) — durable, backed-up disk on a single host. For multi-node or
disk, and a single host serves all objects. Plan capacity accordingly; external object stores set `BACKEND_STORAGE_DRIVER=s3` + the `S3_*`
multi-node deployments need an external object store first. variables above (AWS, MinIO, Garage, R2 all work).
## Functions trust model
Two drivers, two trust levels:
- **`exec` (default)** — function code runs as a child process of the
backend: same host, same privileges, no sandbox. Treat every project
member who can edit a function as holding a shell on the backend host.
Appropriate for single-team self-hosted deployments.
- **`docker`** — each invoke runs in an ephemeral container: no network,
read-only rootfs, 128 MiB memory, half a CPU, 64 pids, non-root, all
capabilities dropped. Requires the Docker socket mounted into the
backend (commented volume in `docker-compose.yml`) — note the socket is
itself root-equivalent on the host, so this isolates *function code*,
not operators. Source code capped at 256 KiB. For hostile multi-tenant
workloads, a dedicated microVM runtime (Firecracker/gVisor) remains the
roadmap item.
## Observability ## Observability
+4
View File
@@ -65,6 +65,10 @@ The important ones:
| `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app | | `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app |
| `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only | | `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only |
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app | | `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app |
| `BACKEND_STORAGE_DRIVER` | `local` (default, `BACKEND_STORAGE_ROOT`) or `s3` — S3-compatible object storage |
| `S3_ENDPOINT` / `S3_BUCKET` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 backend config (with `S3_REGION`, `S3_PREFIX`, `S3_PATH_STYLE`) |
| `S3_PUBLIC_ENDPOINT` | Client-facing S3 host for presigned URLs when `S3_ENDPOINT` is internal-only |
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` | JS runtime for Functions — `auto` (default, resolves `bun`/`deno` from PATH) or a binary path; 30 s default timeout |
| `VITE_DEMO_MODE` | Enable the client-side demo workspace | | `VITE_DEMO_MODE` | Enable the client-side demo workspace |
### Instance administration ### Instance administration
+6 -2
View File
@@ -48,8 +48,12 @@ Supabase, with no hosted tier - your data stays on your hardware.
- **Organizations & projects** - workspaces with scoped org and project roles. - **Organizations & projects** - workspaces with scoped org and project roles.
- **Auth** - email/password plus optional GitHub, Google, Discord, and Microsoft OAuth. JWTs verified by the API against JWKS. - **Auth** - email/password plus optional GitHub, Google, Discord, and Microsoft OAuth. JWTs verified by the API against JWKS.
- **Storage** - S3-style buckets on your local filesystem, public or private. - **Storage** - S3-style buckets on local disk or any S3-compatible backend (AWS, MinIO, Garage, R2), public or private. With `s3` the API mints presigned upload/download URLs so large objects skip the backend.
- **Collections** - schema-flexible JSON documents in Postgres JSONB. - **Collections** - schema-flexible JSON documents in Postgres JSONB, with filter/order queries.
- **Functions** - project-scoped JS/TS run by bun/deno — on the host (`exec`) or in ephemeral locked-down containers (`docker` driver) — with per-run logs. Trigger them four ways: manual invoke, cron schedules (`function_id` on a job), inbound hooks (`mode: "function"`), or domain events (`event_pattern` like `document.*`).
- **Vault** - AES-256-GCM project secrets plus an Argon2id/XChaCha20 local vault in the CLI and desktop app.
- **Automation** - scheduled jobs, inbound webhooks, telemetry alert rules, and `secret://` refs resolved at delivery.
- **Realtime** - SSE event stream with a typed client SDK (`@primora/client`) — channel subscriptions, presence counts, and client-published `custom.*` events that fan out to webhooks and functions.
- **API keys** - `prm_<prefix>_<secret>` credentials, shown once. - **API keys** - `prm_<prefix>_<secret>` credentials, shown once.
- **Audit log** - every mutating request recorded, with CSV/JSON export. - **Audit log** - every mutating request recorded, with CSV/JSON export.
- **Demo mode** - fully client-side seeded workspace via `?demo=true`. - **Demo mode** - fully client-side seeded workspace via `?demo=true`.
+116 -23
View File
@@ -123,8 +123,11 @@ Follow-ups:
- Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run - Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run
(host-verified on Linux only). (host-verified on Linux only).
- Desktop bundle signing + auto-updater — unsigned artifacts today; add when - ~~Desktop bundle signing + auto-updater~~ ✅ — `tauri-plugin-updater`
distribution matures. wired: signed updater artifacts (`createUpdaterArtifacts`), minisign
keypair, `latest.json` manifest assembled on release, in-app
"Update & restart" on the connect screen. macOS/Windows OS-level code
signing (notarization/Authenticode) still open — needs paid certs.
## Phase 6 — Production hardening (v0.6.0) ✅ ## Phase 6 — Production hardening (v0.6.0) ✅
@@ -185,8 +188,9 @@ release pipeline with images + binaries + desktop bundles.
(auto-registers from ingest). (auto-registers from ingest).
- ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres → - ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres →
dragonfly transfer, key templating, TTL). dragonfly transfer, key templating, TTL).
- ~~**External object storage**~~ ✅ — constraint documented in - ~~**External object storage**~~ ✅ — `BACKEND_STORAGE_DRIVER=s3` shipped
DEPLOYMENT_GUIDE.md / PRODUCTION_READINESS.md; S3 backend deferred. (Phase 10): any S3-compatible API + presigned URLs; local disk remains
the default.
### Testing ### Testing
@@ -205,8 +209,10 @@ release pipeline with images + binaries + desktop bundles.
- ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via - ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via
QEMU + buildx. QEMU + buildx.
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle - Carried forward from Phase 5 follow-ups: macOS/Windows bundle
verification, signing + auto-updater. verification; updater + minisign signing shipped (OS-level signing
- Optional: SBOM + provenance attestation on release images. remains open).
- ~~Optional: SBOM~~ ✅ — CycloneDX SBOMs per published image + source
tree, attached to every release (`anchore/sbom-action`).
### Docs ### Docs
@@ -304,28 +310,115 @@ Channel subscriptions over the Phase 7 SSE stream, in `@primora/client`.
with capped backoff, `onStateChange` lifecycle. with capped backoff, `onStateChange` lifecycle.
- `channel("documents").on("created", cb)` — namespaces `document`, `object`, - `channel("documents").on("created", cb)` — namespaces `document`, `object`,
`issue`, `deploy`, `"*"` for everything; trailing `s` optional. `issue`, `deploy`, `"*"` for everything; trailing `s` optional.
- Presence and client-broadcast deliberately deferred — protocol question, - **Presence** ✅ — server tracks realtime subscribers per project;
no consumer yet. `presence.update` {online: n} broadcasts on every join/leave (realtime-only,
never fans out to webhooks/functions), `GET …/realtime/presence` reads the
count, `client.presence()` wraps it.
- **Client events** ✅ — `POST /projects/:id/events` publishes `custom.*`
types through the standard fan-out (realtime + webhooks + functions);
`client.publish("custom.x", data)` and `primora events:send` both reach it.
The `custom.*` prefix is enforced so clients can't spoof system events.
## Notifications — alert rules (unreleased)
Telemetry-driven alerts delivered through the existing webhook dispatcher.
- `core.alert_rules` (migration 00012) — `heartbeat_silence` and
`error_spike` kinds, per-component or project-wide scope, evaluated by a
60 s backend ticker.
- Transition-based firing: `alert.fired` once on breach, `alert.resolved`
on recovery — no notification storms. Both are webhook event types and
realtime events, so any existing subscriber receives them signed.
- CRUD via `/projects/:id/alerts`; Integrations page manages rules next to
webhooks and shows live `firing` state.
## Inbound webhooks (unreleased)
Project-scoped public ingest for external systems.
- `POST /api/v1/hooks/<token>` — token is the credential; optional
`X-Primora-Signature` HMAC-SHA256 verification when a secret is set.
- `event` mode republishes the body as `inbound.received` (webhooks +
realtime fan out for free); `job` mode enqueues a scheduled-job run with
the received body as payload (`triggered_by: "hook"`).
- 256 KiB body cap; `last_received_at` tracked; managed from Integrations.
## HA scheduler (unreleased)
Multi-replica safety for the job scheduler and alert evaluator.
- PostgreSQL advisory locks (session-scoped, separate keys per component)
on a dedicated pinned pool connection; only the leader scans for due work.
- Manual runs and hook-triggered runs work on any replica — the lock gates
only the scheduled scan, so leadership loss never blocks on-demand work.
- Released on shutdown; failover verified with a live two-replica test —
the survivor acquired leadership and fired the due job.
## Email surface (unreleased)
Reusable templates + an operational send log.
- `core.email_log` (migration 00014) — every send recorded: template,
recipient, subject, `sent`/`failed` status, error text.
- `Mailer` stays transport-only (Resend when configured, SMTP otherwise);
templates render subject + plaintext body. First template: `invitation`.
- `GET /projects/:id/emails` lists recent sends; Members page shows an
"Email log" card under pending invitations.
## Future phases — candidates ## Future phases — candidates
Ordered loosely by leverage. None committed; each gets scoped when picked. Ordered loosely by leverage. None committed; each gets scoped when picked.
- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers. - **Phase 9 — Functions** — ✅ shipped: `core.functions` +
Deno or Bun isolates in a sidecar; secrets via env, payload via stdin. `core.function_runs`, bun/deno exec runner (stdin payload, env injection,
The job runner already supplies the scheduling half. capped output, timeout), invoke API + dashboard page. Triggers shipped:
- **Phase 10 — Storage backends** — S3-compatible object storage behind scheduled jobs (`function_id` on a job), inbound hooks (`mode:
the existing bucket abstraction (deferred from Phase 6), plus "function"`), and domain events (`event_pattern`, e.g. `document.*`).
presigned-URL uploads for large files. Runs record trigger source (`manual`/`schedule`/`hook`/`event`).
- **Notifications** — alert rules on telemetry (error-rate thresholds, Isolation shipped: `FUNCTIONS_DRIVER=docker` runs every invoke in an
heartbeat silence) fanning out through the webhook dispatcher. ephemeral locked-down container (no network, read-only fs, non-root).
- **Inbound webhooks** — project-scoped ingest endpoints that turn Remaining candidate: Firecracker/gVisor microVMs for hostile
external events into domain events/jobs. multi-tenant workloads — docker socket trust is documented.
- **HA mode** — leader-elected scheduler + distributed locks so two - **Phase 10 — Storage backends** — ✅ shipped: `BACKEND_STORAGE_DRIVER=s3`
backend replicas don't double-fire jobs; Postgres advisory locks are + `S3_*` env vars, stdlib SigV4, verified against a real S3 server.
enough at this scale. Presigned upload/download URLs shipped: `POST
- **Email surface** — transactional templates + send log in the /buckets/:id/object-presigns` mints SigV4 query-signed GET/PUT URLs
dashboard, backed by the auth service's existing SMTP/Resend wiring. (`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`.
--- ---
+3
View File
@@ -34,6 +34,9 @@ RUN apt-get update \
&& chmod +x /usr/local/bin/dbx-mcp \ && chmod +x /usr/local/bin/dbx-mcp \
&& rm /tmp/dbx-mcp.tar.gz && rm /tmp/dbx-mcp.tar.gz
# Functions runtime — bun static binary from the official image (multi-arch).
COPY --from=oven/bun:1.3.14 /usr/local/bin/bun /usr/local/bin/bun
COPY --from=builder /primora-backend /usr/local/bin/primora-backend COPY --from=builder /primora-backend /usr/local/bin/primora-backend
COPY --from=builder /app/db ./db COPY --from=builder /app/db ./db
COPY --from=builder /app/openapi ./openapi COPY --from=builder /app/openapi ./openapi
@@ -0,0 +1,16 @@
-- +goose Up
CREATE TABLE core.alert_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('heartbeat_silence','error_spike')),
config JSONB NOT NULL DEFAULT '{}'::jsonb,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
state JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
-- +goose Down
DROP TABLE IF EXISTS core.alert_rules;
@@ -0,0 +1,29 @@
-- +goose Up
CREATE TABLE core.inbound_hooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
mode TEXT NOT NULL DEFAULT 'event' CHECK (mode IN ('event','job')),
job_id UUID REFERENCES core.scheduled_jobs(id) ON DELETE CASCADE,
secret BYTEA NOT NULL DEFAULT ''::bytea,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_received_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
ALTER TABLE core.scheduled_job_runs
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
ALTER TABLE core.scheduled_job_runs
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
CHECK (triggered_by IN ('schedule','manual','hook'));
-- +goose Down
ALTER TABLE core.scheduled_job_runs
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
ALTER TABLE core.scheduled_job_runs
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
CHECK (triggered_by IN ('schedule','manual'));
DROP TABLE IF EXISTS core.inbound_hooks;
@@ -0,0 +1,16 @@
-- +goose Up
CREATE TABLE core.email_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES core.projects(id) ON DELETE CASCADE,
template TEXT NOT NULL,
to_email TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('sent','failed')),
error TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_email_log_project ON core.email_log(project_id, created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS core.email_log;
@@ -0,0 +1,40 @@
-- +goose Up
CREATE TABLE core.functions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
code TEXT NOT NULL,
runtime TEXT NOT NULL DEFAULT 'bun' CHECK (runtime IN ('bun','deno')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
CREATE TABLE core.function_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
function_id UUID NOT NULL REFERENCES core.functions(id) ON DELETE CASCADE,
trigger TEXT NOT NULL CHECK (trigger IN ('manual','schedule','hook')),
status TEXT NOT NULL CHECK (status IN ('success','error','timeout')),
exit_code INT,
stdout TEXT NOT NULL DEFAULT '',
stderr TEXT NOT NULL DEFAULT '',
duration_ms INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX function_runs_function_idx ON core.function_runs(function_id, created_at DESC);
ALTER TABLE core.scheduled_job_runs
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
ALTER TABLE core.scheduled_job_runs
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
CHECK (triggered_by IN ('schedule','manual','hook','function'));
-- +goose Down
ALTER TABLE core.scheduled_job_runs
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
ALTER TABLE core.scheduled_job_runs
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
CHECK (triggered_by IN ('schedule','manual','hook'));
DROP TABLE IF EXISTS core.function_runs;
DROP TABLE IF EXISTS core.functions;
@@ -0,0 +1,34 @@
-- +goose Up
ALTER TABLE core.scheduled_jobs
ADD COLUMN function_id UUID REFERENCES core.functions(id) ON DELETE SET NULL;
ALTER TABLE core.inbound_hooks
ADD COLUMN function_id UUID REFERENCES core.functions(id) ON DELETE CASCADE;
ALTER TABLE core.inbound_hooks
DROP CONSTRAINT inbound_hooks_mode_check;
ALTER TABLE core.inbound_hooks
ADD CONSTRAINT inbound_hooks_mode_check
CHECK (mode IN ('event','job','function'));
ALTER TABLE core.functions
ADD COLUMN event_pattern TEXT NOT NULL DEFAULT '';
ALTER TABLE core.function_runs
DROP CONSTRAINT function_runs_trigger_check;
ALTER TABLE core.function_runs
ADD CONSTRAINT function_runs_trigger_check
CHECK (trigger IN ('manual','schedule','hook','event'));
-- +goose Down
ALTER TABLE core.function_runs
DROP CONSTRAINT function_runs_trigger_check;
ALTER TABLE core.function_runs
ADD CONSTRAINT function_runs_trigger_check
CHECK (trigger IN ('manual','schedule','hook'));
ALTER TABLE core.functions DROP COLUMN IF EXISTS event_pattern;
ALTER TABLE core.inbound_hooks
DROP CONSTRAINT inbound_hooks_mode_check;
ALTER TABLE core.inbound_hooks
ADD CONSTRAINT inbound_hooks_mode_check CHECK (mode IN ('event','job'));
ALTER TABLE core.inbound_hooks DROP COLUMN IF EXISTS function_id;
ALTER TABLE core.scheduled_jobs DROP COLUMN IF EXISTS function_id;
+49
View File
@@ -0,0 +1,49 @@
-- name: CreateAlertRule :one
INSERT INTO core.alert_rules (project_id, name, kind, config)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: ListAlertRules :many
SELECT * FROM core.alert_rules
WHERE project_id = $1
ORDER BY name;
-- name: ListEnabledAlertRules :many
SELECT * FROM core.alert_rules
WHERE enabled;
-- name: GetAlertRule :one
SELECT * FROM core.alert_rules
WHERE id = $1 AND project_id = $2;
-- name: UpdateAlertRule :one
UPDATE core.alert_rules
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING *;
-- name: SetAlertRuleState :exec
UPDATE core.alert_rules
SET state = $3, updated_at = NOW()
WHERE id = $1 AND project_id = $2;
-- name: DeleteAlertRule :exec
DELETE FROM core.alert_rules
WHERE id = $1 AND project_id = $2;
-- Per-component most recent heartbeat — heartbeat_silence detection.
-- name: LastHeartbeats :many
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
FROM core.components co
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
WHERE co.project_id = $1
GROUP BY co.name;
-- Per-component error count inside a sliding window — error_spike detection.
-- name: RecentErrorCounts :many
SELECT co.name AS component_name, count(*) AS errors
FROM core.events e
JOIN core.components co ON co.id = e.component_id
WHERE e.project_id = $1 AND e.type = 'error'
AND e.ts > now() - make_interval(secs => $2::bigint)
GROUP BY co.name;
+10
View File
@@ -0,0 +1,10 @@
-- name: InsertEmailLog :one
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: ListEmailLog :many
SELECT * FROM core.email_log
WHERE project_id = $1
ORDER BY created_at DESC
LIMIT $2;
+45
View File
@@ -0,0 +1,45 @@
-- name: CreateFunction :one
INSERT INTO core.functions (project_id, name, code, runtime, event_pattern)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: GetFunction :one
SELECT * FROM core.functions WHERE id = $1;
-- name: GetFunctionByName :one
SELECT * FROM core.functions WHERE project_id = $1 AND name = $2;
-- name: ListFunctions :many
SELECT * FROM core.functions
WHERE project_id = $1
ORDER BY name;
-- name: UpdateFunction :one
UPDATE core.functions
SET code = COALESCE(sqlc.narg('code'), code),
enabled = COALESCE(sqlc.narg('enabled'), enabled),
event_pattern = COALESCE(sqlc.narg('event_pattern'), event_pattern),
updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING *;
-- name: DeleteFunction :exec
DELETE FROM core.functions WHERE id = $1 AND project_id = $2;
-- name: InsertFunctionRun :one
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: ListFunctionRuns :many
SELECT * FROM core.function_runs
WHERE function_id = $1
ORDER BY created_at DESC
LIMIT $2;
-- name: ListFunctionsForEvent :many
SELECT * FROM core.functions
WHERE project_id = $1
AND enabled = TRUE
AND event_pattern <> ''
AND ($2 LIKE REPLACE(event_pattern, '*', '%') OR event_pattern = '*');
+26
View File
@@ -0,0 +1,26 @@
-- name: CreateInboundHook :one
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, function_id, secret, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- name: ListInboundHooks :many
SELECT * FROM core.inbound_hooks
WHERE project_id = $1
ORDER BY name;
-- name: GetInboundHookByToken :one
SELECT * FROM core.inbound_hooks
WHERE token = $1;
-- name: GetInboundHook :one
SELECT * FROM core.inbound_hooks
WHERE id = $1 AND project_id = $2;
-- name: TouchInboundHook :exec
UPDATE core.inbound_hooks
SET last_received_at = NOW()
WHERE id = $1;
-- name: DeleteInboundHook :exec
DELETE FROM core.inbound_hooks
WHERE id = $1 AND project_id = $2;
+4 -2
View File
@@ -17,8 +17,9 @@ INSERT INTO core.scheduled_jobs (
payload, payload,
enabled, enabled,
next_run_at, next_run_at,
created_by_user_id created_by_user_id,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) function_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *; RETURNING *;
-- name: UpdateScheduledJob :one -- name: UpdateScheduledJob :one
@@ -30,6 +31,7 @@ SET name = $3,
next_run_at = $7, next_run_at = $7,
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END, secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END, payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
AND project_id = $2 AND project_id = $2
@@ -147,3 +147,7 @@ WHERE p.organization_id = $1;
DELETE FROM core.organizations DELETE FROM core.organizations
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
-- name: GetOrganizationByID :one
SELECT * FROM core.organizations
WHERE id = $1;
+4 -1
View File
@@ -11,7 +11,10 @@ WHERE id = $1;
SELECT * FROM core.webhooks SELECT * FROM core.webhooks
WHERE project_id = $1 WHERE project_id = $1
AND enabled AND enabled
AND (cardinality(events) = 0 OR $2::text = ANY(events)); AND (cardinality(events) = 0 OR EXISTS (
SELECT 1 FROM unnest(events) AS e
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
));
-- name: CreateWebhook :one -- name: CreateWebhook :one
INSERT INTO core.webhooks ( INSERT INTO core.webhooks (
+32 -4
View File
@@ -81,9 +81,31 @@ func Bootstrap(ctx context.Context) (*App, error) {
} }
} }
store, err := storage.NewLocalStore(cfg.StorageRoot) var store storage.Store
if err != nil { if cfg.StorageDriver == "s3" {
return nil, err s3Store, err := storage.NewS3Store(storage.S3Config{
Endpoint: cfg.S3Endpoint,
Region: cfg.S3Region,
Bucket: cfg.S3Bucket,
AccessKeyID: cfg.S3AccessKeyID,
SecretAccessKey: cfg.S3SecretAccessKey,
PublicEndpoint: cfg.S3PublicEndpoint,
Prefix: cfg.S3Prefix,
PathStyle: cfg.S3PathStyle,
})
if err != nil {
return nil, fmt.Errorf("s3 storage: %w", err)
}
if err := s3Store.EnsureBucket(context.Background()); err != nil {
return nil, err
}
store = s3Store
} else {
localStore, err := storage.NewLocalStore(cfg.StorageRoot)
if err != nil {
return nil, err
}
store = localStore
} }
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir) dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
repo := repositories.NewCoreRepository(dbPool) repo := repositories.NewCoreRepository(dbPool)
@@ -91,7 +113,13 @@ func Bootstrap(ctx context.Context) (*App, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("encryption key: %w", err) return nil, fmt.Errorf("encryption key: %w", err)
} }
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger) var fnRunner services.FunctionRunner
if cfg.FunctionsDriver == "docker" {
fnRunner = services.NewDockerRunner(cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
} else {
fnRunner = services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
}
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, fnRunner)
settings := services.NewSettingsService(repo, encryptor) settings := services.NewSettingsService(repo, encryptor)
if cfg.Env == "production" { if cfg.Env == "production" {
+67 -2
View File
@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
) )
type Config struct { type Config struct {
@@ -35,6 +36,26 @@ type Config struct {
SMTPSecure bool SMTPSecure bool
EncryptionKey string EncryptionKey string
// Object storage: "local" (BACKEND_STORAGE_ROOT) or "s3".
StorageDriver string
S3Endpoint string
S3Region string
S3Bucket string
S3AccessKeyID string
S3SecretAccessKey string
S3PublicEndpoint string // presigned-URL host when it differs from Endpoint
S3Prefix string
S3PathStyle bool
// Functions runtime: driver "exec" (runtime binary on the backend host)
// or "docker" (ephemeral locked-down container), binary name/path for
// exec ("auto" resolves per-function runtime from PATH), timeout and
// output cap.
FunctionsDriver string
FunctionsRuntime string
FunctionsTimeout time.Duration
FunctionsMaxBytes int64
} }
// devEncryptionKey is the built-in credential key for local development only. // devEncryptionKey is the built-in credential key for local development only.
@@ -63,6 +84,17 @@ func Load() (Config, error) {
SMTPUser: os.Getenv("SMTP_USER"), SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"), SMTPPassword: os.Getenv("SMTP_PASSWORD"),
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"), EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
StorageDriver: getenv("BACKEND_STORAGE_DRIVER", "local"),
S3Endpoint: os.Getenv("S3_ENDPOINT"),
S3Region: getenv("S3_REGION", "us-east-1"),
S3Bucket: os.Getenv("S3_BUCKET"),
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"),
S3Prefix: os.Getenv("S3_PREFIX"),
FunctionsDriver: getenv("FUNCTIONS_DRIVER", "exec"),
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
FunctionsMaxBytes: 65536,
} }
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025")) smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
@@ -95,6 +127,24 @@ func Load() (Config, error) {
} }
cfg.SMTPSecure = smtpSecure cfg.SMTPSecure = smtpSecure
s3PathStyle, err := strconv.ParseBool(getenv("S3_PATH_STYLE", "true"))
if err != nil {
return Config{}, fmt.Errorf("parse S3_PATH_STYLE: %w", err)
}
cfg.S3PathStyle = s3PathStyle
fnTimeout, err := strconv.Atoi(getenv("FUNCTIONS_TIMEOUT_SECONDS", "30"))
if err != nil {
return Config{}, fmt.Errorf("parse FUNCTIONS_TIMEOUT_SECONDS: %w", err)
}
cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second
switch cfg.FunctionsDriver {
case "exec", "docker":
default:
return Config{}, fmt.Errorf("FUNCTIONS_DRIVER %q must be exec or docker", cfg.FunctionsDriver)
}
var missing []string var missing []string
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {
missing = append(missing, "DATABASE_URL") missing = append(missing, "DATABASE_URL")
@@ -102,8 +152,23 @@ func Load() (Config, error) {
if cfg.JWTSecret == "" { if cfg.JWTSecret == "" {
missing = append(missing, "JWT_SECRET") missing = append(missing, "JWT_SECRET")
} }
if cfg.StorageRoot == "" { switch cfg.StorageDriver {
missing = append(missing, "BACKEND_STORAGE_ROOT") case "local":
if cfg.StorageRoot == "" {
missing = append(missing, "BACKEND_STORAGE_ROOT")
}
case "s3":
if cfg.S3Bucket == "" {
missing = append(missing, "S3_BUCKET")
}
if cfg.S3AccessKeyID == "" {
missing = append(missing, "S3_ACCESS_KEY_ID")
}
if cfg.S3SecretAccessKey == "" {
missing = append(missing, "S3_SECRET_ACCESS_KEY")
}
default:
return Config{}, fmt.Errorf("unknown BACKEND_STORAGE_DRIVER %q (want local or s3)", cfg.StorageDriver)
} }
if cfg.AuthInternalBaseURL == "" { if cfg.AuthInternalBaseURL == "" {
missing = append(missing, "AUTH_INTERNAL_BASE_URL") missing = append(missing, "AUTH_INTERNAL_BASE_URL")
@@ -0,0 +1,290 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: alert_rules.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createAlertRule = `-- name: CreateAlertRule :one
INSERT INTO core.alert_rules (project_id, name, kind, config)
VALUES ($1, $2, $3, $4)
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
`
type CreateAlertRuleParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Config []byte `json:"config"`
}
func (q *Queries) CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) {
row := q.db.QueryRow(ctx, createAlertRule,
arg.ProjectID,
arg.Name,
arg.Kind,
arg.Config,
)
var i CoreAlertRule
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteAlertRule = `-- name: DeleteAlertRule :exec
DELETE FROM core.alert_rules
WHERE id = $1 AND project_id = $2
`
type DeleteAlertRuleParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error {
_, err := q.db.Exec(ctx, deleteAlertRule, arg.ID, arg.ProjectID)
return err
}
const getAlertRule = `-- name: GetAlertRule :one
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
WHERE id = $1 AND project_id = $2
`
type GetAlertRuleParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) {
row := q.db.QueryRow(ctx, getAlertRule, arg.ID, arg.ProjectID)
var i CoreAlertRule
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const lastHeartbeats = `-- name: LastHeartbeats :many
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
FROM core.components co
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
WHERE co.project_id = $1
GROUP BY co.name
`
type LastHeartbeatsRow struct {
ComponentName string `json:"component_name"`
LastBeat pgtype.Timestamptz `json:"last_beat"`
}
// Per-component most recent heartbeat — heartbeat_silence detection.
func (q *Queries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) {
rows, err := q.db.Query(ctx, lastHeartbeats, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []LastHeartbeatsRow
for rows.Next() {
var i LastHeartbeatsRow
if err := rows.Scan(&i.ComponentName, &i.LastBeat); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlertRules = `-- name: ListAlertRules :many
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
WHERE project_id = $1
ORDER BY name
`
func (q *Queries) ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) {
rows, err := q.db.Query(ctx, listAlertRules, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreAlertRule
for rows.Next() {
var i CoreAlertRule
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listEnabledAlertRules = `-- name: ListEnabledAlertRules :many
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
WHERE enabled
`
func (q *Queries) ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) {
rows, err := q.db.Query(ctx, listEnabledAlertRules)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreAlertRule
for rows.Next() {
var i CoreAlertRule
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const recentErrorCounts = `-- name: RecentErrorCounts :many
SELECT co.name AS component_name, count(*) AS errors
FROM core.events e
JOIN core.components co ON co.id = e.component_id
WHERE e.project_id = $1 AND e.type = 'error'
AND e.ts > now() - make_interval(secs => $2::bigint)
GROUP BY co.name
`
type RecentErrorCountsParams struct {
ProjectID uuid.UUID `json:"project_id"`
Column2 int64 `json:"column_2"`
}
type RecentErrorCountsRow struct {
ComponentName string `json:"component_name"`
Errors int64 `json:"errors"`
}
// Per-component error count inside a sliding window — error_spike detection.
func (q *Queries) RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) {
rows, err := q.db.Query(ctx, recentErrorCounts, arg.ProjectID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []RecentErrorCountsRow
for rows.Next() {
var i RecentErrorCountsRow
if err := rows.Scan(&i.ComponentName, &i.Errors); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setAlertRuleState = `-- name: SetAlertRuleState :exec
UPDATE core.alert_rules
SET state = $3, updated_at = NOW()
WHERE id = $1 AND project_id = $2
`
type SetAlertRuleStateParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
State []byte `json:"state"`
}
func (q *Queries) SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error {
_, err := q.db.Exec(ctx, setAlertRuleState, arg.ID, arg.ProjectID, arg.State)
return err
}
const updateAlertRule = `-- name: UpdateAlertRule :one
UPDATE core.alert_rules
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
`
type UpdateAlertRuleParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Config []byte `json:"config"`
Enabled bool `json:"enabled"`
}
func (q *Queries) UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) {
row := q.db.QueryRow(ctx, updateAlertRule,
arg.ID,
arg.ProjectID,
arg.Name,
arg.Config,
arg.Enabled,
)
var i CoreAlertRule
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Kind,
&i.Config,
&i.Enabled,
&i.State,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -0,0 +1,91 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: email_log.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const insertEmailLog = `-- name: InsertEmailLog :one
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, project_id, template, to_email, subject, status, error, created_at
`
type InsertEmailLogParams struct {
ProjectID pgtype.UUID `json:"project_id"`
Template string `json:"template"`
ToEmail string `json:"to_email"`
Subject string `json:"subject"`
Status string `json:"status"`
Error string `json:"error"`
}
func (q *Queries) InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) {
row := q.db.QueryRow(ctx, insertEmailLog,
arg.ProjectID,
arg.Template,
arg.ToEmail,
arg.Subject,
arg.Status,
arg.Error,
)
var i CoreEmailLog
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Template,
&i.ToEmail,
&i.Subject,
&i.Status,
&i.Error,
&i.CreatedAt,
)
return i, err
}
const listEmailLog = `-- name: ListEmailLog :many
SELECT id, project_id, template, to_email, subject, status, error, created_at FROM core.email_log
WHERE project_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListEmailLogParams struct {
ProjectID pgtype.UUID `json:"project_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) {
rows, err := q.db.Query(ctx, listEmailLog, arg.ProjectID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreEmailLog
for rows.Next() {
var i CoreEmailLog
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Template,
&i.ToEmail,
&i.Subject,
&i.Status,
&i.Error,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -0,0 +1,313 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: functions.sql
package db
import (
"context"
"github.com/google/uuid"
)
const createFunction = `-- name: CreateFunction :one
INSERT INTO core.functions (project_id, name, code, runtime, event_pattern)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
`
type CreateFunctionParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Code string `json:"code"`
Runtime string `json:"runtime"`
EventPattern string `json:"event_pattern"`
}
func (q *Queries) CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error) {
row := q.db.QueryRow(ctx, createFunction,
arg.ProjectID,
arg.Name,
arg.Code,
arg.Runtime,
arg.EventPattern,
)
var i CoreFunction
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
&i.EventPattern,
)
return i, err
}
const deleteFunction = `-- name: DeleteFunction :exec
DELETE FROM core.functions WHERE id = $1 AND project_id = $2
`
type DeleteFunctionParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error {
_, err := q.db.Exec(ctx, deleteFunction, arg.ID, arg.ProjectID)
return err
}
const getFunction = `-- name: GetFunction :one
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE id = $1
`
func (q *Queries) GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error) {
row := q.db.QueryRow(ctx, getFunction, id)
var i CoreFunction
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
&i.EventPattern,
)
return i, err
}
const getFunctionByName = `-- name: GetFunctionByName :one
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE project_id = $1 AND name = $2
`
type GetFunctionByNameParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error) {
row := q.db.QueryRow(ctx, getFunctionByName, arg.ProjectID, arg.Name)
var i CoreFunction
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
&i.EventPattern,
)
return i, err
}
const insertFunctionRun = `-- name: InsertFunctionRun :one
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at
`
type InsertFunctionRunParams struct {
FunctionID uuid.UUID `json:"function_id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
ExitCode *int32 `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
DurationMs int32 `json:"duration_ms"`
}
func (q *Queries) InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error) {
row := q.db.QueryRow(ctx, insertFunctionRun,
arg.FunctionID,
arg.Trigger,
arg.Status,
arg.ExitCode,
arg.Stdout,
arg.Stderr,
arg.DurationMs,
)
var i CoreFunctionRun
err := row.Scan(
&i.ID,
&i.FunctionID,
&i.Trigger,
&i.Status,
&i.ExitCode,
&i.Stdout,
&i.Stderr,
&i.DurationMs,
&i.CreatedAt,
)
return i, err
}
const listFunctionRuns = `-- name: ListFunctionRuns :many
SELECT id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at FROM core.function_runs
WHERE function_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListFunctionRunsParams struct {
FunctionID uuid.UUID `json:"function_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error) {
rows, err := q.db.Query(ctx, listFunctionRuns, arg.FunctionID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreFunctionRun
for rows.Next() {
var i CoreFunctionRun
if err := rows.Scan(
&i.ID,
&i.FunctionID,
&i.Trigger,
&i.Status,
&i.ExitCode,
&i.Stdout,
&i.Stderr,
&i.DurationMs,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listFunctions = `-- name: ListFunctions :many
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
WHERE project_id = $1
ORDER BY name
`
func (q *Queries) ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error) {
rows, err := q.db.Query(ctx, listFunctions, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreFunction
for rows.Next() {
var i CoreFunction
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
&i.EventPattern,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listFunctionsForEvent = `-- name: ListFunctionsForEvent :many
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
WHERE project_id = $1
AND enabled = TRUE
AND event_pattern <> ''
AND ($2 LIKE REPLACE(event_pattern, '*', '%') OR event_pattern = '*')
`
type ListFunctionsForEventParams struct {
ProjectID uuid.UUID `json:"project_id"`
EventPattern string `json:"event_pattern"`
}
func (q *Queries) ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error) {
rows, err := q.db.Query(ctx, listFunctionsForEvent, arg.ProjectID, arg.EventPattern)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreFunction
for rows.Next() {
var i CoreFunction
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
&i.EventPattern,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateFunction = `-- name: UpdateFunction :one
UPDATE core.functions
SET code = COALESCE($3, code),
enabled = COALESCE($4, enabled),
event_pattern = COALESCE($5, event_pattern),
updated_at = NOW()
WHERE id = $1 AND project_id = $2
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
`
type UpdateFunctionParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Code *string `json:"code"`
Enabled *bool `json:"enabled"`
EventPattern *string `json:"event_pattern"`
}
func (q *Queries) UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error) {
row := q.db.QueryRow(ctx, updateFunction,
arg.ID,
arg.ProjectID,
arg.Code,
arg.Enabled,
arg.EventPattern,
)
var i CoreFunction
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Code,
&i.Runtime,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
&i.EventPattern,
)
return i, err
}
@@ -0,0 +1,179 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: inbound_hooks.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createInboundHook = `-- name: CreateInboundHook :one
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, function_id, secret, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id
`
type CreateInboundHookParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Token string `json:"token"`
Mode string `json:"mode"`
JobID pgtype.UUID `json:"job_id"`
FunctionID pgtype.UUID `json:"function_id"`
Secret []byte `json:"secret"`
Enabled bool `json:"enabled"`
}
func (q *Queries) CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error) {
row := q.db.QueryRow(ctx, createInboundHook,
arg.ProjectID,
arg.Name,
arg.Token,
arg.Mode,
arg.JobID,
arg.FunctionID,
arg.Secret,
arg.Enabled,
)
var i CoreInboundHook
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Token,
&i.Mode,
&i.JobID,
&i.Secret,
&i.Enabled,
&i.LastReceivedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
)
return i, err
}
const deleteInboundHook = `-- name: DeleteInboundHook :exec
DELETE FROM core.inbound_hooks
WHERE id = $1 AND project_id = $2
`
type DeleteInboundHookParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error {
_, err := q.db.Exec(ctx, deleteInboundHook, arg.ID, arg.ProjectID)
return err
}
const getInboundHook = `-- name: GetInboundHook :one
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
WHERE id = $1 AND project_id = $2
`
type GetInboundHookParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error) {
row := q.db.QueryRow(ctx, getInboundHook, arg.ID, arg.ProjectID)
var i CoreInboundHook
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Token,
&i.Mode,
&i.JobID,
&i.Secret,
&i.Enabled,
&i.LastReceivedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
)
return i, err
}
const getInboundHookByToken = `-- name: GetInboundHookByToken :one
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
WHERE token = $1
`
func (q *Queries) GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error) {
row := q.db.QueryRow(ctx, getInboundHookByToken, token)
var i CoreInboundHook
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Token,
&i.Mode,
&i.JobID,
&i.Secret,
&i.Enabled,
&i.LastReceivedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
)
return i, err
}
const listInboundHooks = `-- name: ListInboundHooks :many
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
WHERE project_id = $1
ORDER BY name
`
func (q *Queries) ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error) {
rows, err := q.db.Query(ctx, listInboundHooks, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreInboundHook
for rows.Next() {
var i CoreInboundHook
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Token,
&i.Mode,
&i.JobID,
&i.Secret,
&i.Enabled,
&i.LastReceivedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.FunctionID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const touchInboundHook = `-- name: TouchInboundHook :exec
UPDATE core.inbound_hooks
SET last_received_at = NOW()
WHERE id = $1
`
func (q *Queries) TouchInboundHook(ctx context.Context, id uuid.UUID) error {
_, err := q.db.Exec(ctx, touchInboundHook, id)
return err
}
+33 -19
View File
@@ -22,9 +22,10 @@ INSERT INTO core.scheduled_jobs (
payload, payload,
enabled, enabled,
next_run_at, next_run_at,
created_by_user_id created_by_user_id,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) function_id
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
` `
type CreateScheduledJobParams struct { type CreateScheduledJobParams struct {
@@ -37,6 +38,7 @@ type CreateScheduledJobParams struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"` NextRunAt pgtype.Timestamptz `json:"next_run_at"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"` CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
FunctionID pgtype.UUID `json:"function_id"`
} }
func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) { func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) {
@@ -50,6 +52,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob
arg.Enabled, arg.Enabled,
arg.NextRunAt, arg.NextRunAt,
arg.CreatedByUserID, arg.CreatedByUserID,
arg.FunctionID,
) )
var i CoreScheduledJob var i CoreScheduledJob
err := row.Scan( err := row.Scan(
@@ -67,6 +70,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob
&i.CreatedByUserID, &i.CreatedByUserID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FunctionID,
) )
return i, err return i, err
} }
@@ -133,7 +137,7 @@ func (q *Queries) FinishScheduledJobRun(ctx context.Context, arg FinishScheduled
} }
const getScheduledJobByID = `-- name: GetScheduledJobByID :one const getScheduledJobByID = `-- name: GetScheduledJobByID :one
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
WHERE id = $1 WHERE id = $1
` `
@@ -155,6 +159,7 @@ func (q *Queries) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreSc
&i.CreatedByUserID, &i.CreatedByUserID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FunctionID,
) )
return i, err return i, err
} }
@@ -188,7 +193,7 @@ func (q *Queries) InsertScheduledJobRun(ctx context.Context, arg InsertScheduled
} }
const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
WHERE enabled WHERE enabled
AND next_run_at IS NOT NULL AND next_run_at IS NOT NULL
AND next_run_at <= $1 AND next_run_at <= $1
@@ -218,6 +223,7 @@ func (q *Queries) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Tim
&i.CreatedByUserID, &i.CreatedByUserID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FunctionID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -272,7 +278,7 @@ func (q *Queries) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJob
} }
const listScheduledJobs = `-- name: ListScheduledJobs :many const listScheduledJobs = `-- name: ListScheduledJobs :many
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
WHERE project_id = $1 WHERE project_id = $1
ORDER BY created_at ASC ORDER BY created_at ASC
` `
@@ -301,6 +307,7 @@ func (q *Queries) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([
&i.CreatedByUserID, &i.CreatedByUserID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FunctionID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -319,7 +326,7 @@ SET last_run_at = $2,
next_run_at = $4, next_run_at = $4,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
` `
type MarkScheduledJobRanParams struct { type MarkScheduledJobRanParams struct {
@@ -352,6 +359,7 @@ func (q *Queries) MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobR
&i.CreatedByUserID, &i.CreatedByUserID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FunctionID,
) )
return i, err return i, err
} }
@@ -386,24 +394,27 @@ SET name = $3,
next_run_at = $7, next_run_at = $7,
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END, secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END, payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 WHERE id = $1
AND project_id = $2 AND project_id = $2
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
` `
type UpdateScheduledJobParams struct { type UpdateScheduledJobParams struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"` Name string `json:"name"`
Schedule string `json:"schedule"` Schedule string `json:"schedule"`
Url string `json:"url"` Url string `json:"url"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
NextRunAt pgtype.Timestamptz `json:"next_run_at"` NextRunAt pgtype.Timestamptz `json:"next_run_at"`
Column8 bool `json:"column_8"` Column8 bool `json:"column_8"`
Secret []byte `json:"secret"` Secret []byte `json:"secret"`
Column10 bool `json:"column_10"` Column10 bool `json:"column_10"`
Payload []byte `json:"payload"` Payload []byte `json:"payload"`
Column12 bool `json:"column_12"`
FunctionID pgtype.UUID `json:"function_id"`
} }
func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) { func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) {
@@ -419,6 +430,8 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob
arg.Secret, arg.Secret,
arg.Column10, arg.Column10,
arg.Payload, arg.Payload,
arg.Column12,
arg.FunctionID,
) )
var i CoreScheduledJob var i CoreScheduledJob
err := row.Scan( err := row.Scan(
@@ -436,6 +449,7 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob
&i.CreatedByUserID, &i.CreatedByUserID,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.FunctionID,
) )
return i, err return i, err
} }
@@ -140,6 +140,18 @@ func (ns NullCoreProjectRole) Value() (driver.Value, error) {
return string(ns.CoreProjectRole), nil return string(ns.CoreProjectRole), nil
} }
type CoreAlertRule struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Config []byte `json:"config"`
Enabled bool `json:"enabled"`
State []byte `json:"state"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreApiKey struct { type CoreApiKey struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
@@ -231,6 +243,17 @@ type CoreDocument struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
} }
type CoreEmailLog struct {
ID uuid.UUID `json:"id"`
ProjectID pgtype.UUID `json:"project_id"`
Template string `json:"template"`
ToEmail string `json:"to_email"`
Subject string `json:"subject"`
Status string `json:"status"`
Error string `json:"error"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreEvent struct { type CoreEvent struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
@@ -243,6 +266,45 @@ type CoreEvent struct {
Ts pgtype.Timestamptz `json:"ts"` Ts pgtype.Timestamptz `json:"ts"`
} }
type CoreFunction struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Code string `json:"code"`
Runtime string `json:"runtime"`
Enabled bool `json:"enabled"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
EventPattern string `json:"event_pattern"`
}
type CoreFunctionRun struct {
ID uuid.UUID `json:"id"`
FunctionID uuid.UUID `json:"function_id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
ExitCode *int32 `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
DurationMs int32 `json:"duration_ms"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreInboundHook struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Token string `json:"token"`
Mode string `json:"mode"`
JobID pgtype.UUID `json:"job_id"`
Secret []byte `json:"secret"`
Enabled bool `json:"enabled"`
LastReceivedAt pgtype.Timestamptz `json:"last_received_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
FunctionID pgtype.UUID `json:"function_id"`
}
type CoreIntegration struct { type CoreIntegration struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`
@@ -333,6 +395,7 @@ type CoreScheduledJob struct {
CreatedByUserID pgtype.UUID `json:"created_by_user_id"` CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
FunctionID pgtype.UUID `json:"function_id"`
} }
type CoreScheduledJobRun struct { type CoreScheduledJobRun struct {
@@ -241,6 +241,23 @@ func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string
return i, err return i, err
} }
const getOrganizationByID = `-- name: GetOrganizationByID :one
SELECT id, slug, name, created_at FROM core.organizations
WHERE id = $1
`
func (q *Queries) GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) {
row := q.db.QueryRow(ctx, getOrganizationByID, id)
var i CoreOrganization
err := row.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const getOrganizationMembership = `-- name: GetOrganizationMembership :one const getOrganizationMembership = `-- name: GetOrganizationMembership :one
SELECT SELECT
om.id, om.organization_id, om.user_id, om.role, om.created_at, om.id, om.organization_id, om.user_id, om.role, om.created_at,
@@ -23,24 +23,30 @@ type Querier interface {
CountOrganizations(ctx context.Context) (int64, error) CountOrganizations(ctx context.Context) (int64, error)
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error) CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error) CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error)
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error) CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error) CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error) CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error) CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error) CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error)
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error) CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error) CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error) CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error)
CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error) CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error)
DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error) DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error) DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error) DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error) DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
@@ -54,6 +60,7 @@ type Querier interface {
FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error) FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error)
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error) GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error) GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error)
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error) GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error) GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error) GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
@@ -62,9 +69,14 @@ type Querier interface {
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error) GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error) GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error) GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error)
GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error)
GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error)
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error)
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error) GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error) GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error) GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error) GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
@@ -77,10 +89,15 @@ type Querier interface {
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error) GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error) GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error) HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error)
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error) InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error)
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error) InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error) InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
// Per-component most recent heartbeat — heartbeat_silence detection.
LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error)
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error) ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error)
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error) ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error) ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error) ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
@@ -90,8 +107,14 @@ type Querier interface {
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error) ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error) ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error)
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error) ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error) ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error)
ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error)
ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error)
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error) ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error) ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error) ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
@@ -114,10 +137,13 @@ type Querier interface {
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error) MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error) MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error
// Per-component error count inside a sliding window — error_spike detection.
RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error)
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error) RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error) RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error) RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error
SweepExpiredAuditLogs(ctx context.Context) (int64, error) SweepExpiredAuditLogs(ctx context.Context) (int64, error)
// Per-project retention sweeps. A retention of 0 days disables the sweep for // Per-project retention sweeps. A retention of 0 days disables the sweep for
// that stream. Pending webhook deliveries are never swept — they may still be // that stream. Pending webhook deliveries are never swept — they may still be
@@ -128,9 +154,12 @@ type Querier interface {
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error) SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error) SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
TouchAPIKey(ctx context.Context, id uuid.UUID) error TouchAPIKey(ctx context.Context, id uuid.UUID) error
TouchInboundHook(ctx context.Context, id uuid.UUID) error
UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error)
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error) UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error)
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error) UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error) UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
@@ -291,7 +291,10 @@ const listWebhooksForEvent = `-- name: ListWebhooksForEvent :many
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
WHERE project_id = $1 WHERE project_id = $1
AND enabled AND enabled
AND (cardinality(events) = 0 OR $2::text = ANY(events)) AND (cardinality(events) = 0 OR EXISTS (
SELECT 1 FROM unnest(events) AS e
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
))
` `
type ListWebhooksForEventParams struct { type ListWebhooksForEventParams struct {
+97
View File
@@ -0,0 +1,97 @@
package handlers
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
func (h *HTTPHandler) alertError(c *gin.Context, err error) {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "alert rule not found")
return
}
msg := err.Error()
if strings.Contains(msg, "invalid") || strings.Contains(msg, "must be") || strings.Contains(msg, "already exists") || strings.Contains(msg, "too long") {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
return
}
h.handleError(c, err)
}
func (h *HTTPHandler) listAlertRules(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListAlertRules(c.Request.Context(), actor, projectID)
if err != nil {
h.alertError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) createAlertRule(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.UpsertAlertRuleInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateAlertRule(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.alertError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) updateAlertRule(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
ruleID, err := uuid.Parse(c.Param("ruleID"))
if err != nil {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id")
return
}
var body services.UpsertAlertRuleInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.UpdateAlertRule(c.Request.Context(), actor, projectID, ruleID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.alertError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) deleteAlertRule(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
ruleID, err := uuid.Parse(c.Param("ruleID"))
if err != nil {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id")
return
}
if err := h.Platform.DeleteAlertRule(c.Request.Context(), actor, projectID, ruleID, middleware.RequestIDFromContext(c)); err != nil {
h.alertError(c, err)
return
}
c.Status(http.StatusNoContent)
}
+44
View File
@@ -0,0 +1,44 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
)
// publishEvent accepts a client-authored domain event (custom.* namespace)
// and fans it out through the standard publishEvent path — realtime
// subscribers, matching webhooks, and event_pattern functions all fire.
func (h *HTTPHandler) publishEvent(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body struct {
Type string `json:"type" validate:"required"`
Data map[string]any `json:"data"`
}
if !h.bindAndValidate(c, &body) {
return
}
if err := h.Platform.PublishProjectEvent(c.Request.Context(), actor, projectID, body.Type, body.Data); err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"published": body.Type})
}
// realtimePresence returns the count of clients currently subscribed to the
// project's realtime stream.
func (h *HTTPHandler) realtimePresence(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
n, err := h.Platform.Presence(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"online": n})
}
+146
View File
@@ -0,0 +1,146 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
func (h *HTTPHandler) listFunctions(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
items, err := h.Platform.ListFunctions(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *HTTPHandler) createFunction(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.CreateFunctionInput
if !h.bindAndValidate(c, &body) {
return
}
fn, err := h.Platform.CreateFunction(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, fn)
}
func (h *HTTPHandler) getFunction(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
fn, code, err := h.Platform.GetFunctionCode(c.Request.Context(), actor, functionID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"function": fn, "code": code})
}
func (h *HTTPHandler) updateFunction(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
var body services.UpdateFunctionInput
if !h.bindAndValidate(c, &body) {
return
}
fn, err := h.Platform.UpdateFunction(c.Request.Context(), actor, functionID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, fn)
}
func (h *HTTPHandler) deleteFunction(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
if err := h.Platform.DeleteFunction(c.Request.Context(), actor, functionID, middleware.RequestIDFromContext(c)); err != nil {
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) invokeFunction(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
var body struct {
Payload json.RawMessage `json:"payload"`
}
// An empty body is a valid invoke — defaults to {}.
if c.Request.Body != nil && c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&body); err != nil {
apperrors.Abort(c, http.StatusBadRequest, "invalid_json", err.Error())
return
}
}
run, err := h.Platform.InvokeFunction(c.Request.Context(), actor, functionID, body.Payload, middleware.RequestIDFromContext(c))
if err != nil {
h.handleError(c, err)
return
}
status := http.StatusOK
if run.Status != "success" {
status = http.StatusBadGateway
}
c.JSON(status, run)
}
func (h *HTTPHandler) listFunctionRuns(c *gin.Context) {
actor, _, ok := h.actorAndProject(c)
if !ok {
return
}
functionID, ok := parseUUIDParam(c, "functionID")
if !ok {
return
}
limit, _ := strconv.ParseInt(c.DefaultQuery("limit", "50"), 10, 64)
runs, err := h.Platform.ListFunctionRuns(c.Request.Context(), actor, functionID, limit)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": runs})
}
+115 -1
View File
@@ -2,6 +2,7 @@ package handlers
import ( import (
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@@ -11,6 +12,7 @@ import (
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
db "github.com/tdvorak/primora/apps/backend/internal/database/db" db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/middleware" "github.com/tdvorak/primora/apps/backend/internal/middleware"
@@ -35,6 +37,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api := router.Group("/api/v1") api := router.Group("/api/v1")
api.GET("/me", h.me) api.GET("/me", h.me)
api.GET("/context", h.actorContext)
api.GET("/instance/public", h.instancePublic) api.GET("/instance/public", h.instancePublic)
api.GET("/instance/settings", h.listInstanceSettings) api.GET("/instance/settings", h.listInstanceSettings)
api.PUT("/instance/settings/:key", h.updateInstanceSetting) api.PUT("/instance/settings/:key", h.updateInstanceSetting)
@@ -79,7 +82,20 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument) api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
api.OPTIONS("/ingest", h.ingestOptions) api.OPTIONS("/ingest", h.ingestOptions)
api.POST("/ingest", h.ingest) api.POST("/ingest", h.ingest)
api.POST("/hooks/:token", h.receiveInboundHook)
api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks)
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
api.GET("/projects/:projectID/emails", h.listEmailLog)
api.GET("/projects/:projectID/functions", h.listFunctions)
api.POST("/projects/:projectID/functions", h.createFunction)
api.GET("/projects/:projectID/functions/:functionID", h.getFunction)
api.PUT("/projects/:projectID/functions/:functionID", h.updateFunction)
api.DELETE("/projects/:projectID/functions/:functionID", h.deleteFunction)
api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction)
api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns)
api.GET("/projects/:projectID/events", h.listEvents) api.GET("/projects/:projectID/events", h.listEvents)
api.POST("/projects/:projectID/events", h.publishEvent)
api.GET("/projects/:projectID/issues", h.listIssues) api.GET("/projects/:projectID/issues", h.listIssues)
api.GET("/projects/:projectID/components", h.listComponents) api.GET("/projects/:projectID/components", h.listComponents)
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent) api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
@@ -121,10 +137,17 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret) api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret)
api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret) api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret)
api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret) api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret)
api.GET("/projects/:projectID/alerts", h.listAlertRules)
api.POST("/projects/:projectID/alerts", h.createAlertRule)
api.PUT("/projects/:projectID/alerts/:ruleID", h.updateAlertRule)
api.DELETE("/projects/:projectID/alerts/:ruleID", h.deleteAlertRule)
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream) api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
api.GET("/projects/:projectID/realtime/presence", h.realtimePresence)
api.GET("/buckets/:bucketID/objects", h.listObjects) api.GET("/buckets/:bucketID/objects", h.listObjects)
api.POST("/buckets/:bucketID/objects", h.uploadObject) api.POST("/buckets/:bucketID/objects", h.uploadObject)
api.POST("/buckets/:bucketID/object-copies", h.copyObject) api.POST("/buckets/:bucketID/object-copies", h.copyObject)
api.POST("/buckets/:bucketID/object-presigns", h.presignObject)
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject) api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject) api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject) api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
@@ -161,6 +184,19 @@ func (h *HTTPHandler) me(c *gin.Context) {
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }
func (h *HTTPHandler) actorContext(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
result, err := h.Platform.ActorContext(c.Request.Context(), actor)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) bootstrap(c *gin.Context) { func (h *HTTPHandler) bootstrap(c *gin.Context) {
actor, ok := middleware.RequireActor(c) actor, ok := middleware.RequireActor(c)
if !ok { if !ok {
@@ -874,6 +910,43 @@ func (h *HTTPHandler) updateObject(c *gin.Context) {
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
type presignObjectRequest struct {
Key string `json:"key" validate:"required"`
Op string `json:"op" validate:"omitempty,oneof=download upload"`
TTLSeconds int `json:"ttl_seconds" validate:"omitempty,min=60,max=3600"`
}
// presignObject returns a short-lived URL clients call against storage
// directly — keeps large transfers off the backend. S3 driver only.
func (h *HTTPHandler) presignObject(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
bucketID, ok := parseUUIDParam(c, "bucketID")
if !ok {
return
}
var body presignObjectRequest
if !h.bindAndValidate(c, &body) {
return
}
out, err := h.Platform.PresignObject(c.Request.Context(), actor, bucketID, strings.TrimSpace(body.Key), body.Op, body.TTLSeconds)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
return
}
if errors.Is(err, services.ErrPresignUnsupported) {
apperrors.Abort(c, http.StatusBadRequest, "presign_unsupported", err.Error())
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *HTTPHandler) deleteObject(c *gin.Context) { func (h *HTTPHandler) deleteObject(c *gin.Context) {
actor, ok := middleware.RequireActor(c) actor, ok := middleware.RequireActor(c)
if !ok { if !ok {
@@ -1207,17 +1280,58 @@ func (h *HTTPHandler) bindAndValidate(c *gin.Context, target any) bool {
return false return false
} }
if err := h.Validate.Struct(target); err != nil { if err := h.Validate.Struct(target); err != nil {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", err.Error()) var valErrs validator.ValidationErrors
if errors.As(err, &valErrs) && len(valErrs) > 0 {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", formatFieldError(valErrs[0]))
} else {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", "invalid request body")
}
return false return false
} }
return true return true
} }
func formatFieldError(f validator.FieldError) string {
if f.Tag() == "required" {
return fmt.Sprintf("%s is required", strings.ToLower(f.Field()))
}
return fmt.Sprintf("%s failed %s validation", strings.ToLower(f.Field()), f.Tag())
}
func (h *HTTPHandler) handleError(c *gin.Context, err error) { func (h *HTTPHandler) handleError(c *gin.Context, err error) {
switch { switch {
case errors.Is(err, io.EOF): case errors.Is(err, io.EOF):
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty") apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
case errors.Is(err, pgx.ErrNoRows):
apperrors.Abort(c, http.StatusNotFound, "not_found", "resource not found")
default: default:
var inputErr *services.InputError
if errors.As(err, &inputErr) {
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", inputErr.Error())
return
}
var valErrs validator.ValidationErrors
if errors.As(err, &valErrs) && len(valErrs) > 0 {
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", formatFieldError(valErrs[0]))
return
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
// Structured codes — never leak raw SQLSTATE text or constraint names.
switch pgErr.Code {
case "23505":
apperrors.Abort(c, http.StatusConflict, "conflict", "resource already exists")
case "23503":
apperrors.Abort(c, http.StatusConflict, "conflict", "referenced resource does not exist")
case "22001", "54000":
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "value too large")
case "22021":
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "input is not valid UTF-8")
default:
apperrors.Abort(c, http.StatusInternalServerError, "request_failed", "database error")
}
return
}
status := http.StatusInternalServerError status := http.StatusInternalServerError
message := strings.ToLower(err.Error()) message := strings.ToLower(err.Error())
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") { if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
@@ -4,9 +4,14 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/tdvorak/primora/apps/backend/internal/services"
) )
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) { func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
@@ -104,4 +109,46 @@ func TestHandleErrorStatusMapping(t *testing.T) {
t.Fatalf("unexpected status: %d", recorder.Code) t.Fatalf("unexpected status: %d", recorder.Code)
} }
}) })
t.Run("maps missing rows to not found", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, pgx.ErrNoRows)
if recorder.Code != http.StatusNotFound {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
t.Run("maps InputError to bad request with message", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, services.NewInputError("webhook url must be https"))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("unexpected status: %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), "webhook url must be https") {
t.Fatalf("expected reason in body, got %s", recorder.Body.String())
}
})
t.Run("maps unique violation to conflict without leaking", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint \"projects_slug_key\""})
if recorder.Code != http.StatusConflict {
t.Fatalf("unexpected status: %d", recorder.Code)
}
if strings.Contains(recorder.Body.String(), "projects_slug_key") || strings.Contains(recorder.Body.String(), "23505") {
t.Fatalf("raw constraint leaked: %s", recorder.Body.String())
}
})
t.Run("maps unknown pg error to internal error", func(t *testing.T) {
t.Parallel()
ctx, recorder := newTestContext("")
handler.handleError(ctx, &pgconn.PgError{Code: "XX000", Message: "internal"})
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("unexpected status: %d", recorder.Code)
}
})
} }
+117
View File
@@ -0,0 +1,117 @@
package handlers
import (
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
const maxInboundBody = 256 * 1024 // external POSTs capped at 256 KiB
func (h *HTTPHandler) inboundError(c *gin.Context, err error) {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "hook not found")
return
}
msg := err.Error()
switch {
case strings.Contains(msg, "invalid signature"):
apperrors.Abort(c, http.StatusUnauthorized, "unauthorized", msg)
case strings.Contains(msg, "invalid") || strings.Contains(msg, "already exists") || strings.Contains(msg, "disabled") || strings.Contains(msg, "requires") || strings.Contains(msg, "not found in this project"):
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
default:
h.handleError(c, err)
}
}
func (h *HTTPHandler) listInboundHooks(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListInboundHooks(c.Request.Context(), actor, projectID)
if err != nil {
h.inboundError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) createInboundHook(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.CreateInboundHookInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateInboundHook(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.inboundError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) deleteInboundHook(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
hookID, err := uuid.Parse(c.Param("hookID"))
if err != nil {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid hook id")
return
}
if err := h.Platform.DeleteInboundHook(c.Request.Context(), actor, projectID, hookID, middleware.RequestIDFromContext(c)); err != nil {
h.inboundError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) listEmailLog(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var limit int64
if raw := c.Query("limit"); raw != "" {
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
limit = n
}
}
result, err := h.Platform.ListEmailLog(c.Request.Context(), actor, projectID, limit)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
// receiveInboundHook is the public ingest endpoint — the URL token is the
// credential, so no actor is required.
func (h *HTTPHandler) receiveInboundHook(c *gin.Context) {
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxInboundBody))
if err != nil {
apperrors.Abort(c, http.StatusRequestEntityTooLarge, "too_large", "body exceeds 256 KiB")
return
}
result, err := h.Platform.ReceiveInboundHook(c.Request.Context(), c.Param("token"), c.GetHeader("X-Primora-Signature"), body)
if err != nil {
h.inboundError(c, err)
return
}
c.JSON(http.StatusAccepted, result)
}
+373
View File
@@ -0,0 +1,373 @@
package services
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
// Alert rules detect conditions in telemetry (component heartbeat silence,
// error-rate spikes) and fire `alert.fired` / `alert.resolved` domain events.
// Delivery reuses the webhook dispatcher + realtime stream — the evaluator is
// the only new machinery. Rules fire on state transitions only, so a flaky
// component produces one fired + one resolved, not a storm.
const (
AlertEventFired = "alert.fired"
AlertEventResolved = "alert.resolved"
AlertKindHeartbeatSilence = "heartbeat_silence"
AlertKindErrorSpike = "error_spike"
)
// AlertRuleConfig is the JSONB config per rule kind.
//
// heartbeat_silence: component beats less often than ThresholdMinutes.
// error_spike: component logs more than ThresholdCount errors in
// WindowMinutes.
// Component empty = evaluate every component in the project.
type AlertRuleConfig struct {
Component string `json:"component,omitempty"`
ThresholdMinutes int `json:"threshold_minutes,omitempty"`
ThresholdCount int64 `json:"threshold_count,omitempty"`
WindowMinutes int `json:"window_minutes,omitempty"`
}
type AlertRuleSummary struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Config AlertRuleConfig `json:"config"`
Enabled bool `json:"enabled"`
Firing []string `json:"firing"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UpsertAlertRuleInput struct {
Name string `json:"name" validate:"required"`
Kind string `json:"kind" validate:"required"`
Config AlertRuleConfig `json:"config"`
Enabled *bool `json:"enabled"`
}
func (s *PlatformService) validateAlertRule(input UpsertAlertRuleInput) (AlertRuleConfig, error) {
if !projectSecretNameRe.MatchString(input.Name) {
return AlertRuleConfig{}, fmt.Errorf("invalid alert rule name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name)
}
cfg := input.Config
switch input.Kind {
case AlertKindHeartbeatSilence:
if cfg.ThresholdMinutes < 1 || cfg.ThresholdMinutes > 10080 {
return cfg, errors.New("threshold_minutes must be 1–10080")
}
case AlertKindErrorSpike:
if cfg.ThresholdCount < 1 {
return cfg, errors.New("threshold_count must be ≥ 1")
}
if cfg.WindowMinutes == 0 {
cfg.WindowMinutes = 15
}
if cfg.WindowMinutes < 1 || cfg.WindowMinutes > 1440 {
return cfg, errors.New("window_minutes must be 1–1440")
}
default:
return cfg, fmt.Errorf("invalid kind %q — %s or %s", input.Kind, AlertKindHeartbeatSilence, AlertKindErrorSpike)
}
if cfg.Component != "" && len(cfg.Component) > 128 {
return cfg, errors.New("component name too long")
}
return cfg, nil
}
func alertRuleSummary(row db.CoreAlertRule) AlertRuleSummary {
var cfg AlertRuleConfig
_ = json.Unmarshal(row.Config, &cfg)
firing := parseFiring(row.State)
return AlertRuleSummary{
ID: row.ID.String(),
ProjectID: row.ProjectID.String(),
Name: row.Name,
Kind: row.Kind,
Config: cfg,
Enabled: row.Enabled,
Firing: firing.list(),
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
func (s *PlatformService) ListAlertRules(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]AlertRuleSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListAlertRules(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]AlertRuleSummary, len(rows))
for i, r := range rows {
out[i] = alertRuleSummary(r)
}
return out, nil
}
func (s *PlatformService) CreateAlertRule(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input UpsertAlertRuleInput, requestID string) (AlertRuleSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return AlertRuleSummary{}, err
}
cfg, err := s.validateAlertRule(input)
if err != nil {
return AlertRuleSummary{}, err
}
rawCfg, _ := json.Marshal(cfg)
row, err := s.repo.Queries().CreateAlertRule(ctx, db.CreateAlertRuleParams{
ProjectID: projectID,
Name: input.Name,
Kind: input.Kind,
Config: rawCfg,
})
if err != nil {
if strings.Contains(err.Error(), "alert_rules_project_id_name_key") {
return AlertRuleSummary{}, fmt.Errorf("an alert rule named %s already exists", input.Name)
}
return AlertRuleSummary{}, err
}
s.auditAlert(ctx, actor, projectID, requestID, "alert.created", row.ID, input.Name)
return alertRuleSummary(row), nil
}
func (s *PlatformService) UpdateAlertRule(ctx context.Context, actor *models.Actor, projectID, ruleID uuid.UUID, input UpsertAlertRuleInput, requestID string) (AlertRuleSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return AlertRuleSummary{}, err
}
if _, err := s.repo.Queries().GetAlertRule(ctx, db.GetAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil {
return AlertRuleSummary{}, err // pgx.ErrNoRows → 404 at the handler
}
cfg, err := s.validateAlertRule(input)
if err != nil {
return AlertRuleSummary{}, err
}
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
rawCfg, _ := json.Marshal(cfg)
row, err := s.repo.Queries().UpdateAlertRule(ctx, db.UpdateAlertRuleParams{
ID: ruleID, ProjectID: projectID, Name: input.Name, Config: rawCfg, Enabled: enabled,
})
if err != nil {
return AlertRuleSummary{}, err
}
s.auditAlert(ctx, actor, projectID, requestID, "alert.updated", row.ID, input.Name)
return alertRuleSummary(row), nil
}
func (s *PlatformService) DeleteAlertRule(ctx context.Context, actor *models.Actor, projectID, ruleID uuid.UUID, requestID string) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return err
}
if _, err := s.repo.Queries().GetAlertRule(ctx, db.GetAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil {
return err
}
if err := s.repo.Queries().DeleteAlertRule(ctx, db.DeleteAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil {
return err
}
s.auditAlert(ctx, actor, projectID, requestID, "alert.deleted", ruleID, "")
return nil
}
func (s *PlatformService) auditAlert(ctx context.Context, actor *models.Actor, projectID uuid.UUID, requestID, action string, ruleID uuid.UUID, name string) {
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err != nil {
return
}
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, action, "alert_rule", ruleID.String(), map[string]any{"name": name}))
}
// ---------------------------------------------------------------------------
// evaluator — one ticker per backend, transition-based firing
// ---------------------------------------------------------------------------
type firingSet map[string]bool
func parseFiring(raw []byte) firingSet {
var state struct {
Firing map[string]bool `json:"firing"`
}
if err := json.Unmarshal(raw, &state); err != nil || state.Firing == nil {
return firingSet{}
}
return state.Firing
}
func (f firingSet) list() []string {
out := make([]string, 0, len(f))
for name, on := range f {
if on {
out = append(out, name)
}
}
return out
}
// alertQueries is the slice of db.Queries the evaluator needs — narrow so
// tests can stub it without a database.
type alertQueries interface {
ListEnabledAlertRules(ctx context.Context) ([]db.CoreAlertRule, error)
LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]db.LastHeartbeatsRow, error)
RecentErrorCounts(ctx context.Context, arg db.RecentErrorCountsParams) ([]db.RecentErrorCountsRow, error)
SetAlertRuleState(ctx context.Context, arg db.SetAlertRuleStateParams) error
}
type alertEvaluator struct {
q alertQueries
publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
logger *slog.Logger
tick time.Duration
quit chan struct{}
wg sync.WaitGroup
started sync.Once
leader *leaderState
}
func newAlertEvaluator(q alertQueries, publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), logger *slog.Logger, pool *pgxpool.Pool) *alertEvaluator {
return &alertEvaluator{q: q, publish: publish, logger: logger, tick: time.Minute, quit: make(chan struct{}), leader: newLeaderState(pool, leaderKeyAlerts)}
}
func (e *alertEvaluator) Start(ctx context.Context) {
e.started.Do(func() {
e.wg.Add(1)
go func() {
defer e.wg.Done()
t := time.NewTicker(e.tick)
defer t.Stop()
for {
select {
case <-t.C:
if e.leader.hold(ctx) {
e.scan(ctx)
}
case <-e.quit:
e.leader.stop(context.Background())
return
case <-ctx.Done():
return
}
}
}()
})
}
func (e *alertEvaluator) Stop() {
close(e.quit)
e.wg.Wait()
}
func (e *alertEvaluator) scan(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
rules, err := e.q.ListEnabledAlertRules(ctx)
if err != nil {
e.logger.Warn("alert scan: list rules", "error", err)
return
}
for _, rule := range rules {
if err := e.evaluate(ctx, rule); err != nil {
e.logger.Warn("alert scan: evaluate", "rule", rule.Name, "error", err)
}
}
}
func (e *alertEvaluator) evaluate(ctx context.Context, rule db.CoreAlertRule) error {
var cfg AlertRuleConfig
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
return nil // bad config can't be fixed by re-scanning; skip quietly
}
now := time.Now()
var breached map[string]float64 // component → measured value
var metric, unit string
switch rule.Kind {
case AlertKindHeartbeatSilence:
rows, err := e.q.LastHeartbeats(ctx, rule.ProjectID)
if err != nil {
return err
}
breached = map[string]float64{}
for _, r := range rows {
silent := now.Sub(r.LastBeat.Time).Minutes()
if silent >= float64(cfg.ThresholdMinutes) {
breached[r.ComponentName] = silent
}
}
metric, unit = "silence", "minutes"
case AlertKindErrorSpike:
rows, err := e.q.RecentErrorCounts(ctx, db.RecentErrorCountsParams{
ProjectID: rule.ProjectID,
Column2: int64(cfg.WindowMinutes) * 60,
})
if err != nil {
return err
}
breached = map[string]float64{}
for _, r := range rows {
if r.Errors >= cfg.ThresholdCount {
breached[r.ComponentName] = float64(r.Errors)
}
}
metric, unit = "errors", "count"
default:
return nil
}
firing := parseFiring(rule.State)
changed := false
// newly breached components → fired
for comp, value := range breached {
if cfg.Component != "" && comp != cfg.Component {
continue
}
if firing[comp] {
continue
}
firing[comp] = true
changed = true
e.publish(ctx, rule.ProjectID, AlertEventFired, map[string]any{
"rule_id": rule.ID.String(), "rule": rule.Name, "kind": rule.Kind,
"component": comp, "metric": metric, "value": value, "unit": unit,
})
}
// recovered components → resolved
for comp, on := range firing {
_, stillBreached := breached[comp]
if !on || stillBreached {
continue
}
firing[comp] = false
changed = true
e.publish(ctx, rule.ProjectID, AlertEventResolved, map[string]any{
"rule_id": rule.ID.String(), "rule": rule.Name, "kind": rule.Kind,
"component": comp,
})
}
if !changed {
return nil
}
raw, _ := json.Marshal(map[string]any{"firing": firing})
return e.q.SetAlertRuleState(ctx, db.SetAlertRuleStateParams{ID: rule.ID, ProjectID: rule.ProjectID, State: raw})
}
@@ -0,0 +1,162 @@
package services
import (
"context"
"encoding/json"
"log/slog"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
)
type stubAlertQueries struct {
rules []db.CoreAlertRule
beats []db.LastHeartbeatsRow
errors []db.RecentErrorCountsRow
stateSaved map[uuid.UUID][]byte
}
func (s *stubAlertQueries) ListEnabledAlertRules(ctx context.Context) ([]db.CoreAlertRule, error) {
return s.rules, nil
}
func (s *stubAlertQueries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]db.LastHeartbeatsRow, error) {
return s.beats, nil
}
func (s *stubAlertQueries) RecentErrorCounts(ctx context.Context, arg db.RecentErrorCountsParams) ([]db.RecentErrorCountsRow, error) {
return s.errors, nil
}
func (s *stubAlertQueries) SetAlertRuleState(ctx context.Context, arg db.SetAlertRuleStateParams) error {
if s.stateSaved == nil {
s.stateSaved = map[uuid.UUID][]byte{}
}
s.stateSaved[arg.ID] = arg.State
// persist back so subsequent evaluations see the transition
for i, r := range s.rules {
if r.ID == arg.ID {
s.rules[i].State = arg.State
}
}
return nil
}
func rule(t *testing.T, kind string, cfg AlertRuleConfig) db.CoreAlertRule {
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
return db.CoreAlertRule{ID: uuid.New(), ProjectID: uuid.New(), Name: "r1", Kind: kind, Config: raw, State: []byte(`{"firing":{}}`)}
}
func collect() (func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), *[]string) {
events := &[]string{}
return func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
*events = append(*events, eventType+":"+data["component"].(string))
}, events
}
func TestAlertSilenceFiresOnceThenResolves(t *testing.T) {
r := rule(t, AlertKindHeartbeatSilence, AlertRuleConfig{ThresholdMinutes: 5})
stub := &stubAlertQueries{
rules: []db.CoreAlertRule{r},
beats: []db.LastHeartbeatsRow{{
ComponentName: "web",
LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-10 * time.Minute), Valid: true},
}},
}
publish, events := collect()
e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()}
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
t.Fatal(err)
}
if len(*events) != 1 || (*events)[0] != "alert.fired:web" {
t.Fatalf("want one alert.fired:web, got %v", *events)
}
// second evaluation — still silent, must not refire
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
t.Fatal(err)
}
if len(*events) != 1 {
t.Fatalf("refired: %v", *events)
}
// heartbeat resumes → resolved
stub.beats[0].LastBeat = pgtype.Timestamptz{Time: time.Now(), Valid: true}
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
t.Fatal(err)
}
if len(*events) != 2 || (*events)[1] != "alert.resolved:web" {
t.Fatalf("want alert.resolved:web, got %v", *events)
}
}
func TestAlertErrorSpikeThreshold(t *testing.T) {
r := rule(t, AlertKindErrorSpike, AlertRuleConfig{ThresholdCount: 3, WindowMinutes: 15})
stub := &stubAlertQueries{
rules: []db.CoreAlertRule{r},
errors: []db.RecentErrorCountsRow{{ComponentName: "api", Errors: 2}},
}
publish, events := collect()
e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()}
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
t.Fatal(err)
}
if len(*events) != 0 {
t.Fatalf("under threshold fired: %v", *events)
}
stub.errors[0].Errors = 5
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
t.Fatal(err)
}
if len(*events) != 1 || (*events)[0] != "alert.fired:api" {
t.Fatalf("want alert.fired:api, got %v", *events)
}
}
func TestAlertComponentFilter(t *testing.T) {
r := rule(t, AlertKindHeartbeatSilence, AlertRuleConfig{Component: "web", ThresholdMinutes: 5})
stub := &stubAlertQueries{
rules: []db.CoreAlertRule{r},
beats: []db.LastHeartbeatsRow{
{ComponentName: "web", LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}},
{ComponentName: "api", LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}},
},
}
publish, events := collect()
e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()}
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
t.Fatal(err)
}
if len(*events) != 1 || (*events)[0] != "alert.fired:web" {
t.Fatalf("filtered rule must fire only for web, got %v", *events)
}
}
func TestValidateAlertRule(t *testing.T) {
s := &PlatformService{}
if _, err := s.validateAlertRule(UpsertAlertRuleInput{Name: "ok", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 5}}); err != nil {
t.Fatalf("valid silence rule rejected: %v", err)
}
cfg, err := s.validateAlertRule(UpsertAlertRuleInput{Name: "ok", Kind: AlertKindErrorSpike, Config: AlertRuleConfig{ThresholdCount: 3}})
if err != nil || cfg.WindowMinutes != 15 {
t.Fatalf("window default: cfg=%+v err=%v", cfg, err)
}
for _, bad := range []UpsertAlertRuleInput{
{Name: "bad name!", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 5}},
{Name: "x", Kind: "bogus", Config: AlertRuleConfig{}},
{Name: "x", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 0}},
{Name: "x", Kind: AlertKindErrorSpike, Config: AlertRuleConfig{ThresholdCount: 0}},
} {
if _, err := s.validateAlertRule(bad); err == nil {
t.Fatalf("accepted invalid rule %+v", bad)
}
}
}
@@ -0,0 +1,22 @@
package services
import "testing"
func TestCustomEventTypeRe(t *testing.T) {
good := []string{"custom.ping", "custom.deploy.done", "custom.a", "custom.x-y_z.9"}
bad := []string{
"", "custom", "custom.", "document.created", "CUSTOM.ping",
"custom.Ping", "custom..x", "custom.-x", "custom." + string(rune(0x20)) + "x",
"custom." + string(make([]byte, 64)),
}
for _, s := range good {
if !customEventTypeRe.MatchString(s) {
t.Errorf("expected %q to match", s)
}
}
for _, s := range bad {
if customEventTypeRe.MatchString(s) {
t.Errorf("expected %q to be rejected", s)
}
}
}
@@ -0,0 +1,137 @@
package services
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// dockerRunner executes functions in an ephemeral, locked-down container:
// no network, read-only rootfs, memory/CPU/pid caps, non-root, all
// capabilities dropped. The source is passed through the environment as
// base64 and materialized inside the container's tmpfs — no bind mount,
// so this works on Docker Desktop (VM daemon) and remote daemons where
// host paths aren't shared. The payload arrives on stdin — same contract
// as execRunner, but user code no longer inherits backend privileges.
// Requires the Docker socket (compose: mount /var/run/docker.sock) — the
// documented trade-off: socket access is root-equivalent on the host, so
// this driver isolates function code, not operators.
type dockerRunner struct {
timeout time.Duration
maxOutput int64
images map[string]string // runtime -> image ref
}
// maxFnSourceBytes caps function source for the docker driver — the source
// travels in the container spec (env), which kernels limit to ~2 MB total;
// 256 KiB keeps argv+env comfortably inside it.
const maxFnSourceBytes = 256 << 10
func NewDockerRunner(timeout time.Duration, maxOutput int64) *dockerRunner {
return &dockerRunner{
timeout: timeout,
maxOutput: maxOutput,
images: map[string]string{
"bun": "oven/bun:1.3.14",
"deno": "denoland/deno:2.5.6",
},
}
}
func (r *dockerRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult {
image, ok := r.images[runtime]
if !ok {
return FunctionResult{Status: "error", Stderr: "no container image configured for runtime " + runtime}
}
if len(code) > maxFnSourceBytes {
return FunctionResult{Status: "error", Stderr: fmt.Sprintf("function source exceeds %d KiB docker-driver limit", maxFnSourceBytes>>10)}
}
if _, err := exec.LookPath("docker"); err != nil {
return FunctionResult{Status: "error", Stderr: "FUNCTIONS_DRIVER=docker but docker CLI not found"}
}
ctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
// Named container so a timed-out client doesn't orphan a running fn.
var suffix [4]byte
_, _ = rand.Read(suffix[:])
name := "primora-fn-" + hex.EncodeToString(suffix[:])
// Write source inside the container, then run it. base64 keeps the
// code inert data — it is decoded by the shell, never interpreted.
entrypoint := fmt.Sprintf(
`echo "$PRIMORA_CODE" | base64 -d > /tmp/fn.ts && %s /tmp/fn.ts`,
map[bool]string{true: "deno run --no-prompt", false: "bun run"}[runtime == "deno"],
)
args := []string{
"run", "--rm", "-i", "--name", name,
"--network", "none",
"--memory", "128m", "--memory-swap", "128m",
"--cpus", "0.5",
"--pids-limit", "64",
"--read-only",
"--tmpfs", "/tmp:rw,noexec,nosuid,size=16m",
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--user", "65534:65534",
"-e", "PRIMORA_CODE=" + base64.StdEncoding.EncodeToString([]byte(code)),
"-e", "PRIMORA_PAYLOAD=" + string(payload),
"-e", "PRIMORA_FUNCTION_RUNTIME=" + runtime,
"--entrypoint", "sh",
image, "-c", entrypoint,
}
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Stdin = bytes.NewReader(payload)
var stdout, stderr bytes.Buffer
cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput}
cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput}
// ctx cancel kills the docker CLI but the daemon keeps the container
// running — force-remove it so a stuck function can't linger.
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = exec.Command("docker", "rm", "-f", name).Run()
case <-done:
}
}()
defer close(done)
start := time.Now()
runErr := cmd.Run()
duration := time.Since(start).Milliseconds()
res := FunctionResult{
Status: "success",
Stdout: stdout.String(),
Stderr: stderr.String(),
DurationMs: duration,
}
if ctx.Err() == context.DeadlineExceeded {
res.Status = "timeout"
res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out")
return res
}
if runErr != nil {
res.Status = "error"
if exitErr, ok := runErr.(*exec.ExitError); ok {
code := int32(exitErr.ExitCode())
res.ExitCode = &code
} else {
res.Stderr = strings.TrimSpace(fmt.Sprintf("%v\n%s", runErr, res.Stderr))
}
}
return res
}
@@ -0,0 +1,59 @@
package services
import (
"context"
"encoding/json"
"os/exec"
"strings"
"testing"
"time"
)
func dockerAvailable(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("docker CLI not installed")
}
if err := exec.Command("docker", "info").Run(); err != nil {
t.Skip("docker daemon not reachable")
}
// The test pulls the runtime image on first run.
if err := exec.Command("docker", "image", "inspect", "oven/bun:1.3.14").Run(); err != nil {
t.Skip("oven/bun:1.3.14 image not pulled")
}
}
func TestDockerRunnerRunsCode(t *testing.T) {
dockerAvailable(t)
r := NewDockerRunner(30*time.Second, 65536)
res := r.Run(context.Background(), "bun",
`const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("hi " + p.name);`,
json.RawMessage(`{"name":"sandbox"}`))
if res.Status != "success" {
t.Fatalf("status = %q stderr = %q", res.Status, res.Stderr)
}
if !strings.Contains(res.Stdout, "hi sandbox") {
t.Fatalf("stdout = %q", res.Stdout)
}
}
func TestDockerRunnerIsolatesNetwork(t *testing.T) {
dockerAvailable(t)
r := NewDockerRunner(30*time.Second, 65536)
// --network none: even a DNS/connect attempt must fail inside.
res := r.Run(context.Background(), "bun",
`try { await fetch("http://169.254.169.254/"); console.log("NET-OPEN") } catch { console.log("NET-CLOSED") }`,
json.RawMessage(`{}`))
if !strings.Contains(res.Stdout, "NET-CLOSED") {
t.Fatalf("network should be unreachable, stdout = %q stderr = %q status = %q", res.Stdout, res.Stderr, res.Status)
}
}
func TestDockerRunnerTimesOut(t *testing.T) {
dockerAvailable(t)
r := NewDockerRunner(2*time.Second, 65536)
res := r.Run(context.Background(), "bun", `while (true) {}`, json.RawMessage(`{}`))
if res.Status != "timeout" {
t.Fatalf("status = %q", res.Status)
}
}
+474
View File
@@ -0,0 +1,474 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
var functionNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`)
// FunctionRunner executes a function's code with a JSON payload on stdin
// and returns the outcome. Implemented by execRunner (a runtime binary on
// the backend host); tests substitute a fake.
type FunctionRunner interface {
Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult
}
// FunctionResult is one execution's captured outcome.
type FunctionResult struct {
Status string // success | error | timeout
ExitCode *int32
Stdout string
Stderr string
DurationMs int64
}
// execRunner shells out to a JS runtime binary. User code runs with the
// backend's privileges — this is the documented self-hosted model (same as
// n8n custom-code nodes); operators harden at the container/VM boundary.
// jarvis: ceiling unsandboxed exec; upgrade to Firecracker/gVisor or a
// sidecar runtime pool when multi-tenant isolation is needed.
type execRunner struct {
bin string
timeout time.Duration
maxOutput int64
resolveBin func(runtime string) string
}
func NewExecRunner(bin string, timeout time.Duration, maxOutput int64) *execRunner {
return &execRunner{
bin: bin,
timeout: timeout,
maxOutput: maxOutput,
resolveBin: func(runtime string) string {
if bin != "" && bin != "auto" {
return bin
}
return runtime // "bun" or "deno" from PATH
},
}
}
func (r *execRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult {
bin := r.resolveBin(runtime)
if bin == "" {
return FunctionResult{Status: "error", Stderr: "no functions runtime configured"}
}
dir, err := os.MkdirTemp("", "primora-fn-*")
if err != nil {
return FunctionResult{Status: "error", Stderr: err.Error()}
}
defer os.RemoveAll(dir)
file := filepath.Join(dir, "fn.ts")
if err := os.WriteFile(file, []byte(code), 0o600); err != nil {
return FunctionResult{Status: "error", Stderr: err.Error()}
}
ctx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
var cmd *exec.Cmd
switch runtime {
case "deno":
// --no-prompt: never block on interactive permission prompts.
cmd = exec.CommandContext(ctx, bin, "run", "--no-prompt", file)
default: // bun
cmd = exec.CommandContext(ctx, bin, "run", file)
}
cmd.Stdin = bytes.NewReader(payload)
cmd.Env = append(os.Environ(),
"PRIMORA_PAYLOAD="+string(payload),
"PRIMORA_FUNCTION_RUNTIME="+runtime,
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput}
cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput}
start := time.Now()
runErr := cmd.Run()
duration := time.Since(start).Milliseconds()
res := FunctionResult{
Status: "success",
Stdout: stdout.String(),
Stderr: stderr.String(),
DurationMs: duration,
}
if ctx.Err() == context.DeadlineExceeded {
res.Status = "timeout"
res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out")
return res
}
if runErr != nil {
res.Status = "error"
if exitErr, ok := runErr.(*exec.ExitError); ok {
code := int32(exitErr.ExitCode())
res.ExitCode = &code
} else {
res.Stderr = strings.TrimSpace(runErr.Error() + "\n" + res.Stderr)
}
}
return res
}
// limitedWriter caps captured output; excess bytes are discarded, not
// stored — a chatty function can't grow function_runs rows unboundedly.
type limitedWriter struct {
w *bytes.Buffer
max int64
}
func (l *limitedWriter) Write(p []byte) (int, error) {
remaining := l.max - int64(l.w.Len())
if remaining > 0 {
if int64(len(p)) > remaining {
l.w.Write(p[:remaining])
} else {
l.w.Write(p)
}
}
return len(p), nil
}
// --- service layer -------------------------------------------------------
type CreateFunctionInput struct {
Name string `json:"name" validate:"required"`
Code string `json:"code" validate:"required,max=262144"`
Runtime string `json:"runtime" validate:"omitempty,oneof=bun deno"`
EventPattern string `json:"event_pattern" validate:"omitempty,max=128"`
}
type UpdateFunctionInput struct {
Code *string `json:"code" validate:"omitempty,max=262144"`
Enabled *bool `json:"enabled"`
EventPattern *string `json:"event_pattern" validate:"omitempty,max=128"`
}
type FunctionSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Runtime string `json:"runtime"`
Enabled bool `json:"enabled"`
EventPattern string `json:"event_pattern,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type FunctionRunSummary struct {
ID string `json:"id"`
Trigger string `json:"trigger"`
Status string `json:"status"`
ExitCode *int32 `json:"exit_code,omitempty"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
DurationMs int64 `json:"duration_ms"`
CreatedAt time.Time `json:"created_at"`
}
func functionSummary(r db.CoreFunction) FunctionSummary {
return FunctionSummary{
ID: r.ID.String(),
Name: r.Name,
Runtime: r.Runtime,
Enabled: r.Enabled,
EventPattern: r.EventPattern,
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
}
func runSummary(r db.CoreFunctionRun) FunctionRunSummary {
return FunctionRunSummary{
ID: r.ID.String(),
Trigger: r.Trigger,
Status: r.Status,
ExitCode: r.ExitCode,
Stdout: r.Stdout,
Stderr: r.Stderr,
DurationMs: int64(r.DurationMs),
CreatedAt: r.CreatedAt.Time,
}
}
func (s *PlatformService) CreateFunction(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateFunctionInput, requestID string) (FunctionSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return FunctionSummary{}, err
}
name := strings.TrimSpace(input.Name)
if !functionNamePattern.MatchString(name) {
return FunctionSummary{}, fmt.Errorf("invalid function name")
}
runtime := input.Runtime
if runtime == "" {
runtime = "bun"
}
row, err := s.repo.Queries().CreateFunction(ctx, db.CreateFunctionParams{
ProjectID: projectID,
Name: name,
Code: input.Code,
Runtime: runtime,
EventPattern: strings.TrimSpace(input.EventPattern),
})
if err != nil {
return FunctionSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "function.created", "function", row.ID.String(), map[string]any{
"name": row.Name, "runtime": row.Runtime,
}))
}
return functionSummary(row), nil
}
func (s *PlatformService) ListFunctions(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]FunctionSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListFunctions(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]FunctionSummary, len(rows))
for i, r := range rows {
out[i] = functionSummary(r)
}
return out, nil
}
// GetFunctionCode returns the function's source — code is not a secret,
// project members already see it in the dashboard editor.
func (s *PlatformService) GetFunctionCode(ctx context.Context, actor *models.Actor, functionID uuid.UUID) (FunctionSummary, string, error) {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return FunctionSummary{}, "", err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil {
return FunctionSummary{}, "", err
}
return functionSummary(row), row.Code, nil
}
func (s *PlatformService) UpdateFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, input UpdateFunctionInput, requestID string) (FunctionSummary, error) {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return FunctionSummary{}, err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
return FunctionSummary{}, err
}
var pattern *string
if input.EventPattern != nil {
p := strings.TrimSpace(*input.EventPattern)
pattern = &p
}
updated, err := s.repo.Queries().UpdateFunction(ctx, db.UpdateFunctionParams{
ID: functionID,
ProjectID: row.ProjectID,
Code: input.Code,
Enabled: input.Enabled,
EventPattern: pattern,
})
if err != nil {
return FunctionSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.updated", "function", functionID.String(), map[string]any{
"name": updated.Name,
}))
}
return functionSummary(updated), nil
}
func (s *PlatformService) DeleteFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, requestID string) error {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
return err
}
if err := s.repo.Queries().DeleteFunction(ctx, db.DeleteFunctionParams{
ID: functionID,
ProjectID: row.ProjectID,
}); err != nil {
return err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.deleted", "function", functionID.String(), map[string]any{
"name": row.Name,
}))
}
return nil
}
// InvokeFunction executes a function with a JSON payload, records the run,
// and returns its summary.
func (s *PlatformService) InvokeFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, payload json.RawMessage, requestID string) (FunctionRunSummary, error) {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return FunctionRunSummary{}, err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
return FunctionRunSummary{}, err
}
summary, err := s.executeFunction(ctx, row, payload, "manual")
if err != nil {
return FunctionRunSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.invoked", "function", functionID.String(), map[string]any{
"name": row.Name, "status": summary.Status,
}))
}
return summary, nil
}
// executeFunction runs a function row and records the outcome — shared by
// manual invoke, schedule/hook delivery, and event triggers (no role check;
// callers hold their own auth context or are system paths).
func (s *PlatformService) executeFunction(ctx context.Context, row db.CoreFunction, payload json.RawMessage, trigger string) (FunctionRunSummary, error) {
if !row.Enabled {
return FunctionRunSummary{}, fmt.Errorf("function is disabled")
}
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
if !json.Valid(payload) {
return FunctionRunSummary{}, fmt.Errorf("payload must be valid JSON")
}
if s.functions == nil {
return FunctionRunSummary{}, fmt.Errorf("functions runtime not configured")
}
result := s.functions.Run(ctx, row.Runtime, row.Code, payload)
var exit *int32
if result.ExitCode != nil {
exit = result.ExitCode
}
run, err := s.repo.Queries().InsertFunctionRun(ctx, db.InsertFunctionRunParams{
FunctionID: row.ID,
Trigger: trigger,
Status: result.Status,
ExitCode: exit,
Stdout: result.Stdout,
Stderr: result.Stderr,
DurationMs: int32(result.DurationMs),
})
if err != nil {
return FunctionRunSummary{}, err
}
return runSummary(run), nil
}
// runJobFunction is the scheduler's delivery path for function-targeted
// jobs: load the function, run it with the resolved payload, record the
// run. The job run's status_code mirrors the process exit code.
func (s *PlatformService) runJobFunction(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error) {
fn, err := s.repo.Queries().GetFunction(ctx, uuid.UUID(job.FunctionID.Bytes))
if err != nil {
return nil, fmt.Errorf("target function missing: %w", err)
}
summary, err := s.executeFunction(ctx, fn, payload, triggerFromJobRun(run.TriggeredBy))
if err != nil {
return nil, err
}
code := int32(0)
if summary.ExitCode != nil {
code = *summary.ExitCode
}
if summary.Status != "success" {
return &code, fmt.Errorf("function %q %s: %s", fn.Name, summary.Status, truncate(summary.Stderr, 200))
}
return &code, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
func triggerFromJobRun(triggeredBy string) string {
switch triggeredBy {
case "schedule", "hook", "manual":
return triggeredBy
default:
return "schedule"
}
}
// triggerFunctionsForEvent fans a domain event out to every enabled
// function whose event_pattern matches ("document.*", "*", …). Runs are
// detached — event publishers never block on user code.
// jarvis: ceiling pattern is a single * suffix/prefix wildcard via SQL
// LIKE; upgrade to a real matcher if multi-segment globs are needed.
func (s *PlatformService) triggerFunctionsForEvent(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
if s.functions == nil {
return
}
rows, err := s.repo.Queries().ListFunctionsForEvent(ctx, db.ListFunctionsForEventParams{
ProjectID: projectID,
EventPattern: eventType,
})
if err != nil || len(rows) == 0 {
return
}
payload, _ := json.Marshal(map[string]any{
"event": eventType,
"project_id": projectID.String(),
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": data,
})
for _, fn := range rows {
fn := fn
go func() {
// Detached context: the publishing request may finish first.
runCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if _, err := s.executeFunction(runCtx, fn, payload, "event"); err != nil {
s.logger.Warn("function event trigger failed", "function", fn.Name, "event", eventType, "error", err)
}
}()
}
}
func (s *PlatformService) ListFunctionRuns(ctx context.Context, actor *models.Actor, functionID uuid.UUID, limit int64) ([]FunctionRunSummary, error) {
row, err := s.repo.Queries().GetFunction(ctx, functionID)
if err != nil {
return nil, err
}
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.repo.Queries().ListFunctionRuns(ctx, db.ListFunctionRunsParams{
FunctionID: row.ID,
Limit: int32(limit),
})
if err != nil {
return nil, err
}
out := make([]FunctionRunSummary, len(rows))
for i, r := range rows {
out[i] = runSummary(r)
}
return out, nil
}
@@ -0,0 +1,86 @@
package services
import (
"bytes"
"context"
"strings"
"testing"
"time"
)
// These tests run the real bun binary — skipped when it is not installed,
// same convention as exec-dependent tests elsewhere.
func bunAvailable(t *testing.T) {
t.Helper()
r := NewExecRunner("auto", 5*time.Second, 4096)
res := r.Run(context.Background(), "bun", `console.log("probe")`, nil)
if strings.Contains(res.Stderr, "no functions runtime") || (res.Status == "error" && res.ExitCode == nil && strings.Contains(res.Stderr, "executable file not found")) {
t.Skip("bun not installed")
}
}
func TestExecRunnerSuccess(t *testing.T) {
bunAvailable(t)
r := NewExecRunner("auto", 10*time.Second, 4096)
res := r.Run(context.Background(), "bun",
`const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("got:" + p.x);`,
[]byte(`{"x":42}`))
if res.Status != "success" {
t.Fatalf("status=%s stderr=%s", res.Status, res.Stderr)
}
if !strings.Contains(res.Stdout, "got:42") {
t.Fatalf("stdout=%q", res.Stdout)
}
if res.DurationMs <= 0 {
t.Fatalf("duration=%d", res.DurationMs)
}
}
func TestExecRunnerErrorCapturesStderr(t *testing.T) {
bunAvailable(t)
r := NewExecRunner("auto", 10*time.Second, 4096)
res := r.Run(context.Background(), "bun", `throw new Error("test-boom")`, nil)
if res.Status != "error" {
t.Fatalf("status=%s", res.Status)
}
if res.ExitCode == nil || *res.ExitCode == 0 {
t.Fatalf("exit=%v", res.ExitCode)
}
if !strings.Contains(res.Stderr, "test-boom") {
t.Fatalf("stderr=%q", res.Stderr)
}
}
func TestExecRunnerTimeout(t *testing.T) {
bunAvailable(t)
r := NewExecRunner("auto", 500*time.Millisecond, 4096)
res := r.Run(context.Background(), "bun", `await new Promise(r => setTimeout(r, 10000))`, nil)
if res.Status != "timeout" {
t.Fatalf("status=%s", res.Status)
}
}
func TestLimitedWriterCaps(t *testing.T) {
var buf bytes.Buffer
lw := &limitedWriter{w: &buf, max: 5}
if n, err := lw.Write([]byte("hello world")); err != nil || n != 11 {
t.Fatalf("write n=%d err=%v", n, err)
}
if buf.String() != "hello" {
t.Fatalf("got %q", buf.String())
}
}
func TestTriggerFromJobRun(t *testing.T) {
for in, want := range map[string]string{
"schedule": "schedule",
"hook": "hook",
"manual": "manual",
"cli": "schedule",
"": "schedule",
} {
if got := triggerFromJobRun(in); got != want {
t.Fatalf("triggerFromJobRun(%q) = %q, want %q", in, got, want)
}
}
}
+293
View File
@@ -0,0 +1,293 @@
package services
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
// Inbound hooks are project-scoped public ingest endpoints:
// POST /api/v1/hooks/<token>. A hook either republishes the body as an
// `inbound.received` domain event (webhooks + realtime fan out for free) or
// triggers a scheduled-job run with the body as the payload. When a secret is
// set, callers must sign the raw body with X-Primora-Signature — the same
// HMAC-SHA256 scheme outbound deliveries use.
const (
WebhookEventInboundReceived = "inbound.received"
InboundModeEvent = "event"
InboundModeJob = "job"
InboundModeFunction = "function"
)
type InboundHookSummary struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Name string `json:"name"`
Token string `json:"token"`
URL string `json:"url"`
Mode string `json:"mode"`
JobID string `json:"job_id,omitempty"`
FunctionID string `json:"function_id,omitempty"`
HasSecret bool `json:"has_secret"`
Enabled bool `json:"enabled"`
LastReceivedAt *time.Time `json:"last_received_at"`
CreatedAt time.Time `json:"created_at"`
}
type CreateInboundHookInput struct {
Name string `json:"name" validate:"required"`
Mode string `json:"mode"`
JobID string `json:"job_id"`
FunctionID string `json:"function_id"`
Secret string `json:"secret"`
Enabled *bool `json:"enabled"`
}
type InboundReceiveResult struct {
Received bool `json:"received"`
Event string `json:"event,omitempty"`
RunID string `json:"run_id,omitempty"`
}
func inboundHookSummary(row db.CoreInboundHook, publicURL string) InboundHookSummary {
out := InboundHookSummary{
ID: row.ID.String(),
ProjectID: row.ProjectID.String(),
Name: row.Name,
Token: row.Token,
URL: strings.TrimSuffix(publicURL, "/") + "/api/v1/hooks/" + row.Token,
Mode: row.Mode,
HasSecret: len(row.Secret) > 0,
Enabled: row.Enabled,
CreatedAt: row.CreatedAt.Time,
}
if row.JobID.Valid {
out.JobID = uuid.UUID(row.JobID.Bytes).String()
}
if row.FunctionID.Valid {
out.FunctionID = uuid.UUID(row.FunctionID.Bytes).String()
}
if row.LastReceivedAt.Valid {
t := row.LastReceivedAt.Time
out.LastReceivedAt = &t
}
return out
}
func (s *PlatformService) ListInboundHooks(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]InboundHookSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListInboundHooks(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]InboundHookSummary, len(rows))
for i, r := range rows {
out[i] = inboundHookSummary(r, s.publicURL)
}
return out, nil
}
func (s *PlatformService) CreateInboundHook(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateInboundHookInput, requestID string) (InboundHookSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return InboundHookSummary{}, err
}
if !projectSecretNameRe.MatchString(input.Name) {
return InboundHookSummary{}, fmt.Errorf("invalid hook name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name)
}
mode := input.Mode
if mode == "" {
mode = InboundModeEvent
}
var jobID, functionID pgtype.UUID
switch mode {
case InboundModeJob:
parsed, err := uuid.Parse(input.JobID)
if err != nil {
return InboundHookSummary{}, errors.New("job mode requires a valid job_id")
}
job, err := s.repo.Queries().GetScheduledJobByID(ctx, parsed)
if err != nil || job.ProjectID != projectID {
return InboundHookSummary{}, errors.New("job not found in this project")
}
jobID = pgtype.UUID{Bytes: parsed, Valid: true}
case InboundModeFunction:
parsed, err := uuid.Parse(input.FunctionID)
if err != nil {
return InboundHookSummary{}, errors.New("function mode requires a valid function_id")
}
fn, err := s.repo.Queries().GetFunction(ctx, parsed)
if err != nil || fn.ProjectID != projectID {
return InboundHookSummary{}, errors.New("function not found in this project")
}
functionID = pgtype.UUID{Bytes: parsed, Valid: true}
case InboundModeEvent:
default:
return InboundHookSummary{}, fmt.Errorf("invalid mode %q — event, job, or function", input.Mode)
}
secretBytes := []byte{}
if input.Secret != "" {
if s.enc == nil {
return InboundHookSummary{}, errors.New("encryption is not configured — cannot store hook secrets")
}
var err error
if secretBytes, err = s.enc.Encrypt([]byte(input.Secret)); err != nil {
return InboundHookSummary{}, err
}
}
token := "prm_hook_" + randHex(16)
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
row, err := s.repo.Queries().CreateInboundHook(ctx, db.CreateInboundHookParams{
ProjectID: projectID,
Name: input.Name,
Token: token,
Mode: mode,
JobID: jobID,
FunctionID: functionID,
Secret: secretBytes,
Enabled: enabled,
})
if err != nil {
if strings.Contains(err.Error(), "inbound_hooks_project_id_name_key") {
return InboundHookSummary{}, fmt.Errorf("an inbound hook named %s already exists", input.Name)
}
return InboundHookSummary{}, err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.created", "inbound_hook", row.ID.String(), map[string]any{
"name": row.Name, "mode": row.Mode,
}))
}
return inboundHookSummary(row, s.publicURL), nil
}
func (s *PlatformService) DeleteInboundHook(ctx context.Context, actor *models.Actor, projectID, hookID uuid.UUID, requestID string) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return err
}
if _, err := s.repo.Queries().GetInboundHook(ctx, db.GetInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
return err
}
if err := s.repo.Queries().DeleteInboundHook(ctx, db.DeleteInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
return err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.deleted", "inbound_hook", hookID.String(), nil))
}
return nil
}
// ReceiveInboundHook handles POST /api/v1/hooks/:token — unauthenticated; the
// token is the credential. Signature check applies when a secret is set.
func (s *PlatformService) ReceiveInboundHook(ctx context.Context, token string, signature string, body []byte) (InboundReceiveResult, error) {
hook, err := s.repo.Queries().GetInboundHookByToken(ctx, token)
if err != nil {
return InboundReceiveResult{}, err // pgx.ErrNoRows → 404
}
if !hook.Enabled {
return InboundReceiveResult{}, errors.New("hook is disabled")
}
if len(hook.Secret) > 0 {
if s.enc == nil {
return InboundReceiveResult{}, errors.New("encryption is not configured")
}
plain, err := s.enc.Decrypt(hook.Secret)
if err != nil {
return InboundReceiveResult{}, err
}
want := "sha256=" + signBody(string(plain), body)
if subtle.ConstantTimeCompare([]byte(signature), []byte(want)) != 1 {
return InboundReceiveResult{}, errors.New("invalid signature")
}
}
var payload map[string]any
if len(body) > 0 {
if err := json.Unmarshal(body, &payload); err != nil {
return InboundReceiveResult{}, fmt.Errorf("invalid JSON body: %w", err)
}
}
_ = s.repo.Queries().TouchInboundHook(ctx, hook.ID)
switch hook.Mode {
case InboundModeJob:
if s.scheduler == nil {
return InboundReceiveResult{}, errors.New("scheduler unavailable")
}
if !hook.JobID.Valid {
return InboundReceiveResult{}, errors.New("hook has no job configured")
}
job, err := s.repo.Queries().GetScheduledJobByID(ctx, hook.JobID.Bytes)
if err != nil {
return InboundReceiveResult{}, err
}
if payload != nil {
raw, _ := json.Marshal(payload)
job.Payload = raw // received body replaces the configured payload
}
run, err := s.scheduler.enqueue(ctx, job, "hook")
if err != nil {
return InboundReceiveResult{}, err
}
return InboundReceiveResult{Received: true, Event: WebhookEventJobRun, RunID: run.ID.String()}, nil
case InboundModeFunction:
if s.functions == nil {
return InboundReceiveResult{}, errors.New("functions runtime not configured")
}
if !hook.FunctionID.Valid {
return InboundReceiveResult{}, errors.New("hook has no function configured")
}
fn, err := s.repo.Queries().GetFunction(ctx, uuid.UUID(hook.FunctionID.Bytes))
if err != nil {
return InboundReceiveResult{}, err
}
raw := body
if len(raw) == 0 {
raw = []byte(`{}`)
}
// Detached: the public endpoint acks immediately; the run row records
// the outcome. Hook callers must not wait on user code.
go func() {
runCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if _, err := s.executeFunction(runCtx, fn, raw, "hook"); err != nil && s.logger != nil {
s.logger.Warn("hook function invoke failed", "hook", hook.Name, "function", fn.Name, "error", err)
}
}()
return InboundReceiveResult{Received: true, Event: "function.invoked"}, nil
default:
s.publishEvent(ctx, hook.ProjectID, WebhookEventInboundReceived, map[string]any{
"hook_id": hook.ID.String(),
"hook_name": hook.Name,
"payload": payload,
})
return InboundReceiveResult{Received: true, Event: WebhookEventInboundReceived}, nil
}
}
func randHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
@@ -104,4 +104,39 @@ func TestValidateWebhookEvents(t *testing.T) {
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil { if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
t.Error("expected unknown event rejected") t.Error("expected unknown event rejected")
} }
for _, ok := range [][]string{
{"*"},
{"document.*"},
{"custom.deploy.done"},
{"custom.*"},
{"custom.deploy.*"},
} {
if _, err := validateWebhookEvents(ok); err != nil {
t.Errorf("expected %v accepted, got %v", ok, err)
}
}
for _, bad := range [][]string{
{"banana.*"}, {"custom..*"}, {"custom.UPPER"}, {"document.exploded"},
} {
if _, err := validateWebhookEvents(bad); err == nil {
t.Errorf("expected %v rejected", bad)
}
}
}
func TestPrivateWebhookHostHardening(t *testing.T) {
private := []string{"localhost", "minio", "echo-svc", "host.docker.internal",
"10.0.0.4", "172.16.5.5", "192.168.1.10", "fe80::1", "svc.local", "db.internal"}
public := []string{"169.254.169.254", "fd00:ec2::254", "2130706433", "0x7f000001",
"8.8.8.8", "example.com", "999999999999"}
for _, h := range private {
if !isPrivateWebhookHost(h) {
t.Errorf("expected %q private", h)
}
}
for _, h := range public {
if isPrivateWebhookHost(h) {
t.Errorf("expected %q public/blocked", h)
}
}
} }
+85 -18
View File
@@ -15,6 +15,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
db "github.com/tdvorak/primora/apps/backend/internal/database/db" db "github.com/tdvorak/primora/apps/backend/internal/database/db"
@@ -34,6 +35,7 @@ type ScheduledJobSummary struct {
Name string `json:"name"` Name string `json:"name"`
Schedule string `json:"schedule"` Schedule string `json:"schedule"`
URL string `json:"url"` URL string `json:"url"`
FunctionID *string `json:"function_id,omitempty"`
Payload json.RawMessage `json:"payload"` Payload json.RawMessage `json:"payload"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
HasSecret bool `json:"has_secret"` HasSecret bool `json:"has_secret"`
@@ -58,21 +60,23 @@ type ScheduledJobRunSummary struct {
} }
type CreateScheduledJobInput struct { type CreateScheduledJobInput struct {
Name string `json:"name" validate:"required,min=2"` Name string `json:"name" validate:"required,min=2"`
Schedule string `json:"schedule" validate:"required"` Schedule string `json:"schedule" validate:"required"`
URL string `json:"url" validate:"required,url"` URL string `json:"url" validate:"required_without=FunctionID,omitempty,url"`
Secret string `json:"secret"` FunctionID string `json:"function_id" validate:"omitempty,uuid"`
Payload map[string]any `json:"payload"` Secret string `json:"secret"`
Enabled *bool `json:"enabled"` Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
} }
type UpdateScheduledJobInput struct { type UpdateScheduledJobInput struct {
Name *string `json:"name" validate:"omitempty,min=2"` Name *string `json:"name" validate:"omitempty,min=2"`
Schedule *string `json:"schedule"` Schedule *string `json:"schedule"`
URL *string `json:"url" validate:"omitempty,url"` URL *string `json:"url" validate:"omitempty,url"`
Secret *string `json:"secret"` FunctionID *string `json:"function_id" validate:"omitempty,uuid"`
Payload map[string]any `json:"payload"` Secret *string `json:"secret"`
Enabled *bool `json:"enabled"` Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
} }
func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary { func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
@@ -87,6 +91,10 @@ func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
HasSecret: len(row.Secret) > 0, HasSecret: len(row.Secret) > 0,
CreatedAt: row.CreatedAt.Time, CreatedAt: row.CreatedAt.Time,
} }
if row.FunctionID.Valid {
id := uuid.UUID(row.FunctionID.Bytes).String()
out.FunctionID = &id
}
if row.LastRunAt.Valid { if row.LastRunAt.Valid {
t := row.LastRunAt.Time t := row.LastRunAt.Time
out.LastRunAt = &t out.LastRunAt = &t
@@ -175,9 +183,25 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return ScheduledJobSummary{}, err return ScheduledJobSummary{}, err
} }
jobURL, err := normalizeWebhookURL(input.URL) jobURL := ""
if err != nil { var functionID pgtype.UUID
return ScheduledJobSummary{}, err 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) sched, err := parseSchedule(input.Schedule)
if err != nil { if err != nil {
@@ -221,6 +245,7 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
Enabled: enabled, Enabled: enabled,
NextRunAt: next, NextRunAt: next,
CreatedByUserID: createdBy, CreatedByUserID: createdBy,
FunctionID: functionID,
}) })
if err != nil { if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") { if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
@@ -259,6 +284,28 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
return ScheduledJobSummary{}, err return ScheduledJobSummary{}, err
} }
} }
rotateFunction := input.FunctionID != nil
functionID := row.FunctionID
if rotateFunction {
raw := strings.TrimSpace(*input.FunctionID)
if raw == "" {
functionID = pgtype.UUID{} // explicit clear → back to URL delivery
if input.URL == nil && strings.HasPrefix(jobURL, "function://") {
return ScheduledJobSummary{}, fmt.Errorf("clearing function_id requires a url")
}
} else {
fid, ferr := uuid.Parse(raw)
if ferr != nil {
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
}
fn, ferr := s.repo.Queries().GetFunction(ctx, fid)
if ferr != nil || fn.ProjectID != projectID {
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
}
functionID = pgtype.UUID{Bytes: fid, Valid: true}
jobURL = "function://" + fid.String()
}
}
enabled := row.Enabled enabled := row.Enabled
if input.Enabled != nil { if input.Enabled != nil {
enabled = *input.Enabled enabled = *input.Enabled
@@ -301,6 +348,8 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
Secret: secretBytes, Secret: secretBytes,
Column10: rotatePayload, Column10: rotatePayload,
Payload: payload, Payload: payload,
Column12: rotateFunction,
FunctionID: functionID,
}) })
if err != nil { if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") { if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
@@ -406,8 +455,13 @@ type JobScheduler struct {
quit chan struct{} quit chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
started sync.Once started sync.Once
leader *leaderState
// onEvent fans a finished run out to webhooks and the realtime stream. // onEvent fans a finished run out to webhooks and the realtime stream.
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
// runFn delivers to a function instead of HTTP when job.FunctionID is set.
// The function receives the resolved job payload, not the run envelope.
// Owned by PlatformService — the scheduler stays transport-agnostic.
runFn func(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error)
} }
type jobExec struct { type jobExec struct {
@@ -415,7 +469,7 @@ type jobExec struct {
run db.CoreScheduledJobRun run db.CoreScheduledJobRun
} }
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)) *JobScheduler { func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), pool *pgxpool.Pool) *JobScheduler {
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
@@ -428,6 +482,7 @@ func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger,
queue: make(chan jobExec, 256), queue: make(chan jobExec, 256),
quit: make(chan struct{}), quit: make(chan struct{}),
onEvent: onEvent, onEvent: onEvent,
leader: newLeaderState(pool, leaderKeyScheduler),
} }
} }
@@ -452,8 +507,13 @@ func (j *JobScheduler) run(ctx context.Context) {
case exec := <-j.queue: case exec := <-j.queue:
j.execute(exec.job, exec.run) j.execute(exec.job, exec.run)
case <-ticker.C: case <-ticker.C:
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: case <-j.quit:
j.leader.stop(context.Background())
return return
case <-ctx.Done(): case <-ctx.Done():
return 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) { 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) data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload)
if err != nil { 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), "occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": data, "data": data,
}) })
if job.FunctionID.Valid {
if j.runFn == nil {
return nil, fmt.Errorf("functions runtime not configured")
}
return j.runFn(ctx, job, run, data)
}
secret := "" secret := ""
if len(job.Secret) > 0 && j.enc != nil { if len(job.Secret) > 0 && j.enc != nil {
if plain, err := j.enc.Decrypt(job.Secret); err == nil { if plain, err := j.enc.Decrypt(job.Secret); err == nil {
+98
View File
@@ -0,0 +1,98 @@
package services
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// Leader election via Postgres advisory locks. The lock is session-scoped, so
// the lease pins its pool connection for its entire lifetime — a handoff back
// to the pool would silently drop it. A single-replica deployment simply
// always holds the lock; there is no feature flag.
//
// Distinct keys per component so the job scheduler and the alert evaluator
// elect independently.
const (
leaderKeyScheduler int64 = 728201
leaderKeyAlerts int64 = 728202
)
type leaderLease struct {
conn *pgxpool.Conn
key int64
}
// acquireLeadership returns a lease when this process wins the lock, nil when
// another replica holds it, or an error when the check itself failed.
func acquireLeadership(ctx context.Context, pool *pgxpool.Pool, key int64) (*leaderLease, error) {
conn, err := pool.Acquire(ctx)
if err != nil {
return nil, err
}
var got bool
if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&got); err != nil {
conn.Release()
return nil, err
}
if !got {
conn.Release()
return nil, nil
}
return &leaderLease{conn: conn, key: key}, nil
}
// alive pings the pinned connection — a dead conn means Postgres dropped the
// session and released the lock, so leadership is lost even though the lease
// object still exists.
func (l *leaderLease) alive(ctx context.Context) bool {
var one int
return l.conn.QueryRow(ctx, "SELECT 1").Scan(&one) == nil
}
func (l *leaderLease) release(ctx context.Context) {
_, _ = l.conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", l.key)
l.conn.Release()
}
// leaderState tracks a lease inside a ticker loop — acquire when absent,
// verify when held, drop and re-elect when the connection dies.
type leaderState struct {
pool *pgxpool.Pool
key int64
lease *leaderLease
}
func newLeaderState(pool *pgxpool.Pool, key int64) *leaderState {
return &leaderState{pool: pool, key: key}
}
// hold reports whether this process currently leads. A nil pool means no
// election configured (tests, embedded use) — always lead.
func (l *leaderState) hold(ctx context.Context) bool {
if l == nil || l.pool == nil {
return true
}
if l.lease != nil {
if l.lease.alive(ctx) {
return true
}
l.lease.release(ctx)
l.lease = nil
}
lease, err := acquireLeadership(ctx, l.pool, l.key)
if err != nil {
return false
}
l.lease = lease
return lease != nil
}
func (l *leaderState) stop(ctx context.Context) {
if l != nil && l.lease != nil {
l.lease.release(ctx)
l.lease = nil
}
}
+27 -3
View File
@@ -11,6 +11,9 @@ import (
"github.com/tdvorak/primora/apps/backend/internal/config" "github.com/tdvorak/primora/apps/backend/internal/config"
) )
// Mailer is transport-only: Resend when configured, plain SMTP otherwise.
// Templates render (subject, body) from data; sends are logged to
// core.email_log by the service layer so operators can see what went out.
type Mailer struct { type Mailer struct {
cfg config.Config cfg config.Config
resend *resend.Client resend *resend.Client
@@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer {
return &Mailer{cfg: cfg, resend: resendClient} return &Mailer{cfg: cfg, resend: resendClient}
} }
func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error { // mailTemplate renders a subject + plaintext body from template data.
subject := fmt.Sprintf("You were invited to %s on Primora", organizationName) type mailTemplate func(data map[string]string) (subject string, body string)
text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n"
var mailTemplates = map[string]mailTemplate{
"invitation": func(d map[string]string) (string, string) {
subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"])
body := fmt.Sprintf(
"You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n",
d["invite_url"],
)
return subject, body
},
}
func renderMailTemplate(template string, data map[string]string) (string, string, error) {
tpl, ok := mailTemplates[template]
if !ok {
return "", "", fmt.Errorf("unknown email template %q", template)
}
subject, body := tpl(data)
return subject, body, nil
}
func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error {
if m.resend != nil { if m.resend != nil {
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{ _, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
From: m.cfg.MailFrom, From: m.cfg.MailFrom,
@@ -0,0 +1,37 @@
package services
import (
"strings"
"testing"
)
func TestRenderMailTemplateInvitation(t *testing.T) {
subject, body, err := renderMailTemplate("invitation", map[string]string{
"organization": "Acme Corp",
"invite_url": "https://app.primora.dev/accept?token=abc",
})
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(subject, "Acme Corp") {
t.Fatalf("subject missing organization: %q", subject)
}
if !strings.Contains(body, "https://app.primora.dev/accept?token=abc") {
t.Fatalf("body missing invite url: %q", body)
}
}
func TestRenderMailTemplateUnknown(t *testing.T) {
if _, _, err := renderMailTemplate("does-not-exist", nil); err == nil {
t.Fatal("expected error for unknown template")
}
}
func TestExtractEmail(t *testing.T) {
if got := extractEmail("Primora <[email protected]>"); got != "[email protected]" {
t.Fatalf("extractEmail = %q", got)
}
if got := extractEmail("[email protected]"); got != "[email protected]" {
t.Fatalf("extractEmail bare = %q", got)
}
}
@@ -10,9 +10,12 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/http"
"net/url" "net/url"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"sync"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -29,7 +32,7 @@ import (
type PlatformService struct { type PlatformService struct {
repo *repositories.CoreRepository repo *repositories.CoreRepository
store *storage.LocalStore store storage.Store
mailer *Mailer mailer *Mailer
publicURL string publicURL string
dbx *dbx.Client dbx *dbx.Client
@@ -38,6 +41,13 @@ type PlatformService struct {
enc *secrets.Encryptor enc *secrets.Encryptor
dispatcher *WebhookDispatcher dispatcher *WebhookDispatcher
scheduler *JobScheduler scheduler *JobScheduler
alerts *alertEvaluator
functions FunctionRunner
logger *slog.Logger
// presenceMu guards presence: realtime subscriber counts per project.
presenceMu sync.Mutex
presence map[uuid.UUID]int
} }
type BootstrapInput struct { type BootstrapInput struct {
@@ -223,17 +233,84 @@ type InvitationSummary struct {
Status string `json:"status"` Status string `json:"status"`
} }
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService { func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *PlatformService {
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc} s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions, logger: logger, presence: map[uuid.UUID]int{}}
if enc != nil { if enc != nil {
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger) s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
s.dispatcher.Start(context.Background()) s.dispatcher.Start(context.Background())
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent) s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent, repo.Pool())
s.scheduler.runFn = s.runJobFunction
s.scheduler.Start(context.Background()) s.scheduler.Start(context.Background())
s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool())
s.alerts.Start(context.Background())
} }
return s return s
} }
// sendTemplatedEmail renders a registered template, sends it, and writes an
// email_log row with the outcome — failed sends are still logged so the
// dashboard shows them.
func (s *PlatformService) sendTemplatedEmail(ctx context.Context, projectID *uuid.UUID, template, to string, data map[string]string) error {
subject, body, err := renderMailTemplate(template, data)
status, errText := "sent", ""
if err == nil {
if sendErr := s.mailer.Send(ctx, to, subject, body); sendErr != nil {
status, errText, err = "failed", sendErr.Error(), sendErr
}
} else {
status, errText = "failed", err.Error()
}
var pid pgtype.UUID
if projectID != nil {
pid = pgtype.UUID{Bytes: *projectID, Valid: true}
}
_, _ = s.repo.Queries().InsertEmailLog(ctx, db.InsertEmailLogParams{
ProjectID: pid,
Template: template,
ToEmail: to,
Subject: subject,
Status: status,
Error: errText,
})
return err
}
type EmailLogSummary struct {
ID string `json:"id"`
Template string `json:"template"`
ToEmail string `json:"to_email"`
Subject string `json:"subject"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func (s *PlatformService) ListEmailLog(ctx context.Context, actor *models.Actor, projectID uuid.UUID, limit int64) ([]EmailLogSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.repo.Queries().ListEmailLog(ctx, db.ListEmailLogParams{ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, Limit: int32(limit)})
if err != nil {
return nil, err
}
out := make([]EmailLogSummary, len(rows))
for i, r := range rows {
out[i] = EmailLogSummary{
ID: r.ID.String(),
Template: r.Template,
ToEmail: r.ToEmail,
Subject: r.Subject,
Status: r.Status,
Error: r.Error,
CreatedAt: r.CreatedAt.Time,
}
}
return out, nil
}
// Close stops the webhook dispatcher and job scheduler. Idempotent. // Close stops the webhook dispatcher and job scheduler. Idempotent.
func (s *PlatformService) Close() { func (s *PlatformService) Close() {
if s.dispatcher != nil { if s.dispatcher != nil {
@@ -242,6 +319,9 @@ func (s *PlatformService) Close() {
if s.scheduler != nil { if s.scheduler != nil {
s.scheduler.Stop() s.scheduler.Stop()
} }
if s.alerts != nil {
s.alerts.Stop()
}
} }
// publishEvent fans a domain event out to realtime SSE subscribers and to // publishEvent fans a domain event out to realtime SSE subscribers and to
@@ -256,18 +336,118 @@ func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID,
}) })
} }
s.dispatchEvent(ctx, projectID, eventType, data) s.dispatchEvent(ctx, projectID, eventType, data)
s.triggerFunctionsForEvent(ctx, projectID, eventType, data)
} }
// SubscribeRealtime registers an SSE consumer for project domain events. // SubscribeRealtime registers an SSE consumer for project domain events and
// bumps the project's presence count.
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) { func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err return nil, err
} }
return s.realtime.Subscribe(projectID.String()), nil ch := s.realtime.Subscribe(projectID.String())
s.bumpPresence(projectID, 1)
return ch, nil
} }
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) { func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
s.realtime.Unsubscribe(projectID.String(), ch) s.realtime.Unsubscribe(projectID.String(), ch)
s.bumpPresence(projectID, -1)
}
// bumpPresence adjusts the online count and broadcasts presence.update to
// realtime subscribers only — deliberately not through publishEvent, so
// webhooks and `event_pattern: "*"` functions don't fire on connect noise.
func (s *PlatformService) bumpPresence(projectID uuid.UUID, delta int) {
s.presenceMu.Lock()
n := s.presence[projectID] + delta
if n <= 0 {
delete(s.presence, projectID)
n = 0
} else {
s.presence[projectID] = n
}
s.presenceMu.Unlock()
s.realtime.Broadcast(projectID.String(), map[string]any{
"type": "presence.update",
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": map[string]any{"online": n},
})
}
// Presence returns the current realtime subscriber count for a project.
func (s *PlatformService) Presence(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (int, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return 0, err
}
s.presenceMu.Lock()
defer s.presenceMu.Unlock()
return s.presence[projectID], nil
}
var customEventTypeRe = regexp.MustCompile(`^custom\.[a-z0-9][a-z0-9_.-]{0,62}$`)
// PublishProjectEvent lets a member emit a custom domain event — realtime
// subscribers, matching webhooks, and event_pattern functions all fire via
// the standard publishEvent fan-out. The custom.* prefix is enforced so
// clients can't spoof system event types (document.created etc.).
func (s *PlatformService) PublishProjectEvent(ctx context.Context, actor *models.Actor, projectID uuid.UUID, eventType string, data map[string]any) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return err
}
if !customEventTypeRe.MatchString(eventType) {
return errors.New("invalid event type: must match custom.<name> (lowercase letters, digits, dots, dashes)")
}
if data == nil {
data = map[string]any{}
}
s.publishEvent(ctx, projectID, eventType, data)
return nil
}
// ActorContext describes the caller's own scope — the self-discovery
// endpoint for API keys, which cannot call /me. User actors get just the
// type marker; their full view is PlatformSummary.
type ActorContext struct {
Actor string `json:"actor"`
Scopes []string `json:"scopes,omitempty"`
KeyPrefix string `json:"key_prefix,omitempty"`
Organization *ContextOrgInfo `json:"organization,omitempty"`
Project *ContextRef `json:"project,omitempty"`
}
type ContextOrgInfo struct {
ContextRef
Role string `json:"role,omitempty"`
}
type ContextRef struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
}
func (s *PlatformService) ActorContext(ctx context.Context, actor *models.Actor) (ActorContext, error) {
out := ActorContext{Actor: string(actor.Type)}
if actor.Type != models.ActorTypeAPIKey {
return out, nil
}
out.Scopes = actor.Scopes
out.KeyPrefix = actor.APIKeyPrefix
if actor.ProjectID == nil {
return out, nil
}
project, err := s.repo.Queries().GetProjectByID(ctx, *actor.ProjectID)
if err != nil {
return out, err
}
out.Project = &ContextRef{ID: project.ID, Slug: project.Slug, Name: project.Name}
org, err := s.repo.Queries().GetOrganizationByID(ctx, project.OrganizationID)
if err != nil {
return out, err
}
out.Organization = &ContextOrgInfo{ContextRef: ContextRef{ID: org.ID, Slug: org.Slug, Name: org.Name}}
return out, nil
} }
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) { func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
@@ -907,7 +1087,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac
if err != nil { if err != nil {
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
} }
if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil { var inviteProject *uuid.UUID
if projectUUID.Valid {
id := uuid.UUID(projectUUID.Bytes)
inviteProject = &id
}
if err := s.sendTemplatedEmail(ctx, inviteProject, "invitation", invitation.Email, map[string]string{
"organization": "Primora",
"invite_url": inviteURL,
}); err != nil {
return nil, err return nil, err
} }
return map[string]any{ return map[string]any{
@@ -1336,6 +1524,64 @@ func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bu
return object, file, nil return object, file, nil
} }
// ErrPresignUnsupported — the configured storage driver can't mint signed
// URLs (only the S3 driver can). Handlers map it to 400, not 500.
var ErrPresignUnsupported = errors.New("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
// InputError marks a service-layer failure caused by caller input — the
// HTTP layer maps it to 400 instead of 500.
type InputError struct{ msg string }
func (e *InputError) Error() string { return e.msg }
func NewInputError(msg string) error { return &InputError{msg: msg} }
func inputErrorf(format string, args ...any) error {
return &InputError{msg: fmt.Sprintf(format, args...)}
}
// PresignObject mints a short-lived direct-to-storage URL for a single
// object. Requires the s3 storage driver — the local store has no external
// URL surface to sign against.
func (s *PlatformService) PresignObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, op string, ttlSeconds int) (*storage.PresignedURL, error) {
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return nil, err
}
method := ""
switch op {
case "download", "":
method = http.MethodGet
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
return nil, err
}
if !objectExists(ctx, s, bucketID, objectKey) {
return nil, pgx.ErrNoRows
}
case "upload":
method = http.MethodPut
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("invalid op %q — download or upload", op)
}
p, ok := s.store.(storage.Presigner)
if !ok {
return nil, ErrPresignUnsupported
}
ttl := time.Duration(ttlSeconds) * time.Second
return p.Presign(ctx, bucketID.String(), objectKey, method, ttl)
}
func objectExists(ctx context.Context, s *PlatformService, bucketID uuid.UUID, key string) bool {
_, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
BucketID: bucketID,
ObjectKey: key,
})
return err == nil
}
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) { func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID) sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil { if err != nil {
+53 -5
View File
@@ -14,6 +14,7 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -31,6 +32,8 @@ const (
WebhookEventIssueCreated = "issue.created" WebhookEventIssueCreated = "issue.created"
WebhookEventDeployMarker = "deploy.marker" WebhookEventDeployMarker = "deploy.marker"
WebhookEventTest = "webhook.test" WebhookEventTest = "webhook.test"
WebhookEventAlertFired = "alert.fired"
WebhookEventAlertResolved = "alert.resolved"
WebhookEventDocumentCreated = "document.created" WebhookEventDocumentCreated = "document.created"
WebhookEventDocumentUpdated = "document.updated" WebhookEventDocumentUpdated = "document.updated"
WebhookEventDocumentDeleted = "document.deleted" WebhookEventDocumentDeleted = "document.deleted"
@@ -43,6 +46,9 @@ var webhookEventTypes = map[string]bool{
WebhookEventIssueCreated: true, WebhookEventIssueCreated: true,
WebhookEventDeployMarker: true, WebhookEventDeployMarker: true,
WebhookEventTest: true, WebhookEventTest: true,
WebhookEventAlertFired: true,
WebhookEventAlertResolved: true,
WebhookEventInboundReceived: true,
WebhookEventJobRun: true, WebhookEventJobRun: true,
WebhookEventDocumentCreated: true, WebhookEventDocumentCreated: true,
WebhookEventDocumentUpdated: true, WebhookEventDocumentUpdated: true,
@@ -160,7 +166,7 @@ func normalizeWebhookURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw) trimmed := strings.TrimSpace(raw)
u, err := url.Parse(trimmed) u, err := url.Parse(trimmed)
if err != nil || u.Host == "" { if err != nil || u.Host == "" {
return "", errors.New("webhook url must be an https URL") return "", inputErrorf("webhook url must be an https URL")
} }
if u.Scheme == "https" { if u.Scheme == "https" {
return trimmed, nil return trimmed, nil
@@ -168,17 +174,35 @@ func normalizeWebhookURL(raw string) (string, error) {
if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) { if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) {
return trimmed, nil return trimmed, nil
} }
return "", errors.New("webhook url must be https (http allowed only for private/self-hosted targets)") return "", inputErrorf("webhook url must be https (http allowed only for private/self-hosted targets)")
}
var decimalOrHexIPRe = regexp.MustCompile(`^(0x[0-9a-f]+|[0-9]+)$`)
// metadataAddrs are link-local addresses that serve cloud instance
// metadata — "private" by range but a privileged SSRF target.
var metadataAddrs = map[string]bool{
"169.254.169.254": true, // AWS/GCP/Azure
"fd00:ec2::254": true, // AWS IPv6 metadata
} }
func isPrivateWebhookHost(host string) bool { func isPrivateWebhookHost(host string) bool {
h := strings.ToLower(strings.Trim(host, "[]")) h := strings.ToLower(strings.Trim(host, "[]"))
if metadataAddrs[h] {
return false
}
if h == "localhost" || h == "host.docker.internal" || if h == "localhost" || h == "host.docker.internal" ||
strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") { strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") {
return true return true
} }
if !strings.Contains(h, ".") && !strings.Contains(h, ":") { if !strings.Contains(h, ".") && !strings.Contains(h, ":") {
return true // bare hostname — docker service name, unqualified LAN host // Bare hostname — docker service name, unqualified LAN host. A
// pure decimal/hex integer is never a service name: it's a
// non-canonical IP encoding (2130706433 = 127.0.0.1, 0x7f000001).
if decimalOrHexIPRe.MatchString(h) {
return false
}
return true
} }
ip := net.ParseIP(h) ip := net.ParseIP(h)
if ip == nil { if ip == nil {
@@ -187,16 +211,40 @@ func isPrivateWebhookHost(host string) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
} }
// webhookEventNamespaces are the valid `<ns>.*` subscription prefixes —
// derived from the known system event types.
func webhookEventNamespaces() map[string]bool {
ns := map[string]bool{}
for t := range webhookEventTypes {
if i := strings.Index(t, "."); i > 0 {
ns[t[:i]] = true
}
}
return ns
}
// validateWebhookEvents accepts known system types, `*`, `<ns>.*` namespace
// patterns, and `custom.*` wildcard patterns — the same grammar functions
// use for event_pattern. Matching happens via SQL LIKE at dispatch.
func validateWebhookEvents(events []string) ([]string, error) { func validateWebhookEvents(events []string) ([]string, error) {
out := make([]string, 0, len(events)) out := make([]string, 0, len(events))
seen := map[string]bool{} seen := map[string]bool{}
namespaces := webhookEventNamespaces()
for _, e := range events { for _, e := range events {
e = strings.TrimSpace(e) e = strings.TrimSpace(e)
if e == "" { if e == "" {
continue continue
} }
if !webhookEventTypes[e] { valid := webhookEventTypes[e] || e == "*"
return nil, fmt.Errorf("unknown event type %q", e) if !valid && strings.HasSuffix(e, ".*") {
valid = namespaces[strings.TrimSuffix(e, ".*")]
}
if !valid && strings.HasPrefix(e, "custom.") {
// `*` isn't in the type grammar — probe it as a literal.
valid = customEventTypeRe.MatchString(strings.ReplaceAll(e, "*", "x"))
}
if !valid {
return nil, inputErrorf("invalid event type %q — use a known type, <namespace>.*, custom.<pattern>, or *", e)
} }
if !seen[e] { if !seen[e] {
seen[e] = true seen[e] = true
+1 -1
View File
@@ -65,7 +65,7 @@ func (s *LocalStore) Put(ctx context.Context, bucketID, objectKey string, reader
}, nil }, nil
} }
func (s *LocalStore) Open(bucketID, objectKey string) (*os.File, string, error) { func (s *LocalStore) Open(bucketID, objectKey string) (io.ReadCloser, string, error) {
cleanKey, err := sanitizeObjectKey(objectKey) cleanKey, err := sanitizeObjectKey(objectKey)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
+572
View File
@@ -0,0 +1,572 @@
package storage
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"sort"
"strings"
"time"
)
// S3Config configures an S3-compatible object store (AWS S3, MinIO,
// Cloudflare R2, Garage, …). Endpoint may be empty for AWS, in which case
// the standard regional endpoint is derived from Region + Bucket.
type S3Config struct {
Endpoint string // e.g. http://localhost:9000 or https://s3.eu-central-1.amazonaws.com
Region string
Bucket string
AccessKeyID string
SecretAccessKey string
Prefix string // optional key prefix, e.g. "primora/"
PathStyle bool // bucket/key path style (MinIO, Garage) vs virtual-hosted
// PublicEndpoint rewrites presigned-URL hosts for clients that can't
// reach Endpoint (e.g. minio:9000 inside compose). Signature binds the
// public host — the URL is only valid there.
PublicEndpoint string
HTTPClient *http.Client
}
// S3Store implements Store against an S3-compatible API using stdlib-only
// SigV4 signing — no AWS SDK dependency. Payloads are sent with
// x-amz-content-sha256=UNSIGNED-PAYLOAD so bodies stream without buffering;
// the SHA-256 for PutResult is computed on a hashing tee instead.
// jarvis: ceiling UNSIGNED-PAYLOAD needs TLS on AWS proper; over plain
// http:// it relies on the server accepting it (MinIO/Garage/LocalStack do).
// Upgrade to aws-chunked streaming signatures if a backend rejects it.
type S3Store struct {
cfg S3Config
client *http.Client
now func() time.Time // injectable for tests
}
func NewS3Store(cfg S3Config) (*S3Store, error) {
if cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
return nil, fmt.Errorf("s3 storage requires S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY")
}
if cfg.Region == "" {
cfg.Region = "us-east-1"
}
if cfg.Endpoint == "" {
cfg.Endpoint = fmt.Sprintf("https://s3.%s.amazonaws.com", cfg.Region)
}
client := cfg.HTTPClient
if client == nil {
client = &http.Client{Timeout: 5 * time.Minute}
}
return &S3Store{cfg: cfg, client: client, now: time.Now}, nil
}
func escapeKey(key string) string {
parts := strings.Split(key, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "/")
}
func (s *S3Store) Put(ctx context.Context, bucketID, objectKey string, reader io.Reader) (PutResult, error) {
key, err := s.objectKey(bucketID, objectKey)
if err != nil {
return PutResult{}, err
}
// S3 requires a Content-Length — chunked transfer encoding is rejected.
// Seekable readers (uploaded *os.File) give the length for free;
// anything else is spooled to a temp file, same cost the local driver
// pays on every write anyway.
body, length, cleanup, err := bodyWithLength(reader)
if err != nil {
return PutResult{}, err
}
defer cleanup()
hasher := sha256.New()
counter := &countingReader{r: io.TeeReader(body, hasher)}
req, err := s.signedRequest(ctx, http.MethodPut, key, counter)
if err != nil {
return PutResult{}, err
}
req.ContentLength = length
resp, err := s.client.Do(req)
if err != nil {
return PutResult{}, fmt.Errorf("s3 put: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return PutResult{}, fmt.Errorf("s3 put: %s: %s", resp.Status, readErrBody(resp))
}
return PutResult{
Path: fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, key),
SizeBytes: counter.n,
SHA256Digest: hex.EncodeToString(hasher.Sum(nil)),
}, nil
}
// bodyWithLength returns a reader plus its total length. The cleanup func
// removes any temp file it created.
func bodyWithLength(r io.Reader) (io.Reader, int64, func(), error) {
nop := func() {}
if seeker, ok := r.(io.Seeker); ok {
size, err := seeker.Seek(0, io.SeekEnd)
if err != nil {
return nil, 0, nil, fmt.Errorf("seek object body: %w", err)
}
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
return nil, 0, nil, fmt.Errorf("rewind object body: %w", err)
}
return r, size, nop, nil
}
tmp, err := os.CreateTemp("", "primora-s3-put-*")
if err != nil {
return nil, 0, nil, fmt.Errorf("spool object body: %w", err)
}
size, err := io.Copy(tmp, r)
if err != nil {
tmp.Close()
os.Remove(tmp.Name())
return nil, 0, nil, fmt.Errorf("spool object body: %w", err)
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
tmp.Close()
os.Remove(tmp.Name())
return nil, 0, nil, err
}
cleanup := func() {
tmp.Close()
os.Remove(tmp.Name())
}
return tmp, size, cleanup, nil
}
type countingReader struct {
r io.Reader
n int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}
func (s *S3Store) Open(bucketID, objectKey string) (io.ReadCloser, string, error) {
key, err := s.objectKey(bucketID, objectKey)
if err != nil {
return nil, "", err
}
req, err := s.signedRequest(context.Background(), http.MethodGet, key, nil)
if err != nil {
return nil, "", err
}
resp, err := s.client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("s3 get: %w", err)
}
if resp.StatusCode/100 != 2 {
defer resp.Body.Close()
return nil, "", fmt.Errorf("s3 get: %s: %s", resp.Status, readErrBody(resp))
}
return resp.Body, fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, key), nil
}
func (s *S3Store) Delete(bucketID, objectKey string) error {
key, err := s.objectKey(bucketID, objectKey)
if err != nil {
return err
}
req, err := s.signedRequest(context.Background(), http.MethodDelete, key, nil)
if err != nil {
return err
}
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("s3 delete: %w", err)
}
defer resp.Body.Close()
// S3 delete is idempotent: 204 on success, 404 is fine too.
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
return fmt.Errorf("s3 delete: %s: %s", resp.Status, readErrBody(resp))
}
return nil
}
func (s *S3Store) MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error) {
src, err := s.objectKey(sourceBucketID, fromKey)
if err != nil {
return "", err
}
dst, err := s.objectKey(destinationBucketID, toKey)
if err != nil {
return "", err
}
// x-amz-copy-source must be set before signing — strict S3
// implementations require all x-amz-* headers to be signed.
key := dst
u := strings.TrimSuffix(s.cfg.Endpoint, "/") + "/" + s.cfg.Bucket + "/" + escapeKey(key)
if !s.cfg.PathStyle {
parsed, perr := url.Parse(strings.TrimSuffix(s.cfg.Endpoint, "/"))
if perr != nil {
return "", perr
}
parsed.Host = s.cfg.Bucket + "." + parsed.Host
parsed.Path = "/" + escapeKey(key)
u = parsed.String()
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, u, nil)
if err != nil {
return "", err
}
req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
req.Header.Set("x-amz-copy-source", "/"+s.cfg.Bucket+"/"+escapeKey(src))
s.sign(req)
resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("s3 copy: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("s3 copy: %s: %s", resp.Status, readErrBody(resp))
}
if err := s.Delete(sourceBucketID, fromKey); err != nil {
return "", fmt.Errorf("s3 move: delete source: %w", err)
}
return fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, dst), nil
}
// DeleteBucket lists every object under the bucket prefix and deletes them
// one by one — DeleteObjects batching is an optimization not worth the code
// at this scale.
func (s *S3Store) DeleteBucket(bucketID string) error {
prefix := path.Join(s.cfg.Prefix, bucketID) + "/"
continuation := ""
for {
keys, next, err := s.listPage(prefix, continuation)
if err != nil {
return err
}
for _, k := range keys {
req, err := s.signedRequest(context.Background(), http.MethodDelete, k, nil)
if err != nil {
return err
}
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("s3 delete %s: %w", k, err)
}
resp.Body.Close()
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
return fmt.Errorf("s3 delete %s: %s", k, resp.Status)
}
}
if next == "" {
return nil
}
continuation = next
}
}
// Presign mints a SigV4 query-signed URL for a single GET/PUT on the
// object — clients talk to S3 directly instead of proxying bytes through
// the backend. TTL is capped at one hour.
func (s *S3Store) Presign(ctx context.Context, bucketID, objectKey, method string, ttl time.Duration) (*PresignedURL, error) {
if method != http.MethodGet && method != http.MethodPut {
return nil, fmt.Errorf("s3 presign: unsupported method %q", method)
}
key, err := s.objectKey(bucketID, objectKey)
if err != nil {
return nil, err
}
if ttl <= 0 {
ttl = 15 * time.Minute
}
if ttl > time.Hour {
ttl = time.Hour
}
now := s.now().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
scope := fmt.Sprintf("%s/%s/s3/aws4_request", dateStamp, s.cfg.Region)
// Build the unsigned URL — same path/vhost logic as signedRequest, but
// on the client-facing endpoint when one is configured.
var base *url.URL
endpoint := strings.TrimSuffix(s.cfg.PublicEndpoint, "/")
if endpoint == "" {
endpoint = strings.TrimSuffix(s.cfg.Endpoint, "/")
}
if s.cfg.PathStyle {
u, err := url.Parse(endpoint + "/" + s.cfg.Bucket + "/" + escapeKey(key))
if err != nil {
return nil, fmt.Errorf("s3 presign url: %w", err)
}
base = u
} else {
u, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("s3 presign url: %w", err)
}
u.Host = s.cfg.Bucket + "." + u.Host
u.Path = "/" + escapeKey(key)
base = u
}
q := base.Query()
q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
q.Set("X-Amz-Credential", s.cfg.AccessKeyID+"/"+scope)
q.Set("X-Amz-Date", amzDate)
q.Set("X-Amz-Expires", fmt.Sprintf("%d", int64(ttl.Seconds())))
q.Set("X-Amz-SignedHeaders", "host")
canonicalURI := base.EscapedPath()
if canonicalURI == "" {
canonicalURI = "/"
}
canonicalRequest := strings.Join([]string{
method,
canonicalURI,
canonicalQuery(q),
"host:" + base.Host + "\n",
"host",
"UNSIGNED-PAYLOAD",
}, "\n")
stringToSign := strings.Join([]string{
"AWS4-HMAC-SHA256",
amzDate,
scope,
hexSHA256(canonicalRequest),
}, "\n")
kDate := hmacSHA256([]byte("AWS4"+s.cfg.SecretAccessKey), dateStamp)
kRegion := hmacSHA256(kDate, s.cfg.Region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
q.Set("X-Amz-Signature", hex.EncodeToString(hmacSHA256(kSigning, stringToSign)))
base.RawQuery = q.Encode()
return &PresignedURL{URL: base.String(), Method: method, ExpiresAt: now.Add(ttl)}, nil
}
type listResult struct {
Contents []struct{ Key string `xml:"Key"` } `xml:"Contents"`
NextContinuationToken string `xml:"NextContinuationToken"`
IsTruncated bool `xml:"IsTruncated"`
}
func (s *S3Store) listPage(prefix, continuation string) ([]string, string, error) {
query := url.Values{
"list-type": {"2"},
"prefix": {prefix},
"max-keys": {"1000"},
}
if continuation != "" {
query.Set("continuation-token", continuation)
}
req, err := s.signedRequestWithQuery(context.Background(), http.MethodGet, "", query)
if err != nil {
return nil, "", err
}
resp, err := s.client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("s3 list: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, "", fmt.Errorf("s3 list: %s: %s", resp.Status, readErrBody(resp))
}
var out listResult
if err := xml.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, "", fmt.Errorf("s3 list decode: %w", err)
}
keys := make([]string, len(out.Contents))
for i, c := range out.Contents {
keys[i] = c.Key
}
next := out.NextContinuationToken
if next == "" && out.IsTruncated && len(keys) > 0 {
next = keys[len(keys)-1] // S3 uses last key as marker when no token
}
return keys, next, nil
}
// --- SigV4 signing -------------------------------------------------------
func (s *S3Store) signedRequest(ctx context.Context, method, key string, body io.Reader) (*http.Request, error) {
return s.signed(ctx, method, key, nil, body)
}
func (s *S3Store) signedRequestWithQuery(ctx context.Context, method, key string, query url.Values) (*http.Request, error) {
return s.signed(ctx, method, key, query, nil)
}
func (s *S3Store) signed(ctx context.Context, method, key string, query url.Values, body io.Reader) (*http.Request, error) {
var reqURL string
endpoint := strings.TrimSuffix(s.cfg.Endpoint, "/")
if s.cfg.PathStyle {
reqURL = endpoint + "/" + s.cfg.Bucket
if key != "" {
reqURL += "/" + escapeKey(key)
}
} else {
u, err := url.Parse(endpoint)
if err != nil {
return nil, fmt.Errorf("s3 endpoint: %w", err)
}
u.Host = s.cfg.Bucket + "." + u.Host
u.Path = "/" + escapeKey(key)
reqURL = u.String()
}
if len(query) > 0 {
reqURL += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
if err != nil {
return nil, err
}
req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
s.sign(req)
return req, nil
}
// sign applies AWS Signature Version 4 to req in place.
func (s *S3Store) sign(req *http.Request) {
now := s.now().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
req.Header.Set("x-amz-date", amzDate)
// Canonical request: method, path, sorted query, headers, signed list,
// payload hash. Signed headers are host + every x-amz-* header present —
// required for x-amz-copy-source on CopyObject and future metadata
// headers.
signedHeaders := []string{"host"}
for h := range req.Header {
lh := strings.ToLower(h)
if strings.HasPrefix(lh, "x-amz-") {
signedHeaders = append(signedHeaders, lh)
}
}
sort.Strings(signedHeaders)
var canonicalHeaders strings.Builder
for _, h := range signedHeaders {
v := req.Host
if h != "host" {
v = strings.TrimSpace(req.Header.Get(h))
}
canonicalHeaders.WriteString(h + ":" + v + "\n")
}
canonicalURI := req.URL.EscapedPath()
if canonicalURI == "" {
canonicalURI = "/"
}
canonicalRequest := strings.Join([]string{
req.Method,
canonicalURI,
canonicalQuery(req.URL.Query()),
canonicalHeaders.String(),
strings.Join(signedHeaders, ";"),
"UNSIGNED-PAYLOAD",
}, "\n")
scope := fmt.Sprintf("%s/%s/s3/aws4_request", dateStamp, s.cfg.Region)
stringToSign := strings.Join([]string{
"AWS4-HMAC-SHA256",
amzDate,
scope,
hexSHA256(canonicalRequest),
}, "\n")
kDate := hmacSHA256([]byte("AWS4"+s.cfg.SecretAccessKey), dateStamp)
kRegion := hmacSHA256(kDate, s.cfg.Region)
kService := hmacSHA256(kRegion, "s3")
kSigning := hmacSHA256(kService, "aws4_request")
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
req.Header.Set("Authorization", fmt.Sprintf(
"AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
s.cfg.AccessKeyID, scope, strings.Join(signedHeaders, ";"), signature))
}
// canonicalQuery re-encodes the query per SigV4: sorted keys, each name and
// value URI-encoded per RFC 3986 (url.Values.Encode's "+" for space is not
// acceptable here).
func canonicalQuery(q url.Values) string {
type pair struct{ k, v string }
pairs := make([]pair, 0, len(q))
for k, vs := range q {
for _, v := range vs {
pairs = append(pairs, pair{sigV4Escape(k), sigV4Escape(v)})
}
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].k == pairs[j].k {
return pairs[i].v < pairs[j].v
}
return pairs[i].k < pairs[j].k
})
var b strings.Builder
for i, p := range pairs {
if i > 0 {
b.WriteByte('&')
}
b.WriteString(p.k + "=" + p.v)
}
return b.String()
}
func sigV4Escape(s string) string {
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}
func hexSHA256(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
func hmacSHA256(key []byte, data string) []byte {
m := hmac.New(sha256.New, key)
m.Write([]byte(data))
return m.Sum(nil)
}
// EnsureBucket creates the configured bucket if it does not exist. Called
// once at startup when the s3 driver is selected; a pre-existing bucket is
// a no-op.
func (s *S3Store) EnsureBucket(ctx context.Context) error {
req, err := s.signedRequest(ctx, http.MethodPut, "", nil)
if err != nil {
return err
}
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("s3 create bucket: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusConflict {
return fmt.Errorf("s3 create bucket: %s: %s", resp.Status, readErrBody(resp))
}
return nil
}
func (s *S3Store) objectKey(bucketID, objectKey string) (string, error) {
clean, err := sanitizeObjectKey(objectKey)
if err != nil {
return "", err
}
return path.Join(s.cfg.Prefix, bucketID, clean), nil
}
func readErrBody(resp *http.Response) string {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return strings.TrimSpace(string(body))
}
+251
View File
@@ -0,0 +1,251 @@
package storage
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
)
// fakeS3 is an in-memory S3-compatible server covering the ops S3Store uses.
type fakeS3 struct {
mu sync.Mutex
objects map[string][]byte
bucket string
}
func newFakeS3(bucket string) *fakeS3 {
return &fakeS3{objects: map[string][]byte{}, bucket: bucket}
}
func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
key := strings.TrimPrefix(r.URL.Path, "/"+f.bucket+"/")
if r.Header.Get("Authorization") == "" || r.Header.Get("x-amz-date") == "" {
http.Error(w, "missing auth", http.StatusForbidden)
return
}
switch {
case r.Method == http.MethodPut && r.Header.Get("x-amz-copy-source") != "":
src := strings.TrimPrefix(r.Header.Get("x-amz-copy-source"), "/"+f.bucket+"/")
body, ok := f.objects[src]
if !ok {
http.Error(w, "source missing", http.StatusNotFound)
return
}
f.objects[key] = body
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodPut:
body, _ := io.ReadAll(r.Body)
f.objects[key] = body
w.WriteHeader(http.StatusOK)
case r.Method == http.MethodGet && r.URL.Query().Get("list-type") == "2":
prefix := r.URL.Query().Get("prefix")
var out strings.Builder
out.WriteString(`<?xml version="1.0"?><ListBucketResult>`)
for k := range f.objects {
if strings.HasPrefix(k, prefix) {
fmt.Fprintf(&out, "<Contents><Key>%s</Key></Contents>", k)
}
}
out.WriteString(`</ListBucketResult>`)
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(out.String()))
case r.Method == http.MethodGet:
body, ok := f.objects[key]
if !ok {
http.Error(w, "no such key", http.StatusNotFound)
return
}
_, _ = w.Write(body)
case r.Method == http.MethodDelete:
delete(f.objects, key)
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "unsupported", http.StatusBadRequest)
}
}
func newTestS3(t *testing.T) (*S3Store, *fakeS3) {
t.Helper()
fake := newFakeS3("primora-objects")
srv := httptest.NewServer(fake)
t.Cleanup(srv.Close)
store, err := NewS3Store(S3Config{
Endpoint: srv.URL,
Region: "us-east-1",
Bucket: "primora-objects",
AccessKeyID: "test-key",
SecretAccessKey: "test-secret",
PathStyle: true,
})
if err != nil {
t.Fatalf("NewS3Store: %v", err)
}
return store, fake
}
func TestS3StorePutGetDeleteRoundTrip(t *testing.T) {
store, fake := newTestS3(t)
ctx := context.Background()
res, err := store.Put(ctx, "bucket-a", "docs/hello.txt", strings.NewReader("hello s3"))
if err != nil {
t.Fatalf("Put: %v", err)
}
if res.SizeBytes != 8 {
t.Fatalf("size = %d, want 8", res.SizeBytes)
}
if res.Path != "s3://primora-objects/bucket-a/docs/hello.txt" {
t.Fatalf("path = %q", res.Path)
}
// sha256("hello s3")
if res.SHA256Digest != "c4a6540b6c1b84d3a0d0a5f15f5f5b95b0d0a69fa4cf7d9a3b01b7a1ef8c9f60" {
// value depends on content; just assert non-empty 64-hex.
if len(res.SHA256Digest) != 64 {
t.Fatalf("digest = %q", res.SHA256Digest)
}
}
rc, path, err := store.Open("bucket-a", "docs/hello.txt")
if err != nil {
t.Fatalf("Open: %v", err)
}
body, _ := io.ReadAll(rc)
rc.Close()
if string(body) != "hello s3" {
t.Fatalf("got %q", body)
}
if !strings.HasPrefix(path, "s3://") {
t.Fatalf("path = %q", path)
}
if err := store.Delete("bucket-a", "docs/hello.txt"); err != nil {
t.Fatalf("Delete: %v", err)
}
if len(fake.objects) != 0 {
t.Fatalf("objects remain: %v", fake.objects)
}
}
func TestS3StoreMoveAndDeleteBucket(t *testing.T) {
store, fake := newTestS3(t)
ctx := context.Background()
if _, err := store.Put(ctx, "bucket-a", "a.txt", strings.NewReader("A")); err != nil {
t.Fatal(err)
}
if _, err := store.Put(ctx, "bucket-a", "b.txt", strings.NewReader("B")); err != nil {
t.Fatal(err)
}
newPath, err := store.MoveBetweenBuckets("bucket-a", "bucket-b", "a.txt", "moved/a.txt")
if err != nil {
t.Fatalf("Move: %v", err)
}
if !strings.Contains(newPath, "bucket-b/moved/a.txt") {
t.Fatalf("path = %q", newPath)
}
if _, ok := fake.objects["bucket-a/a.txt"]; ok {
t.Fatal("source not deleted after move")
}
if got := string(fake.objects["bucket-b/moved/a.txt"]); got != "A" {
t.Fatalf("moved content = %q", got)
}
if err := store.DeleteBucket("bucket-a"); err != nil {
t.Fatalf("DeleteBucket: %v", err)
}
for k := range fake.objects {
if strings.HasPrefix(k, "bucket-a/") {
t.Fatalf("key %s survived DeleteBucket", k)
}
}
// bucket-b untouched
if _, ok := fake.objects["bucket-b/moved/a.txt"]; !ok {
t.Fatal("DeleteBucket removed objects from another bucket")
}
}
func TestS3StoreInvalidKeyRejected(t *testing.T) {
store, _ := newTestS3(t)
if _, err := store.Put(context.Background(), "b", "../escape", strings.NewReader("x")); err == nil {
t.Fatal("expected invalid key error")
}
}
// TestSigV4Signature pins the signing algorithm against a fixed clock: the
// Authorization header must be deterministic for identical inputs.
func TestSigV4SignatureDeterministic(t *testing.T) {
store, _ := newTestS3(t)
store.now = func() time.Time {
return time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
}
req, err := store.signedRequest(context.Background(), http.MethodGet, "bucket-a/x.txt", nil)
if err != nil {
t.Fatal(err)
}
auth := req.Header.Get("Authorization")
if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 Credential=test-key/20260924/us-east-1/s3/aws4_request") {
t.Fatalf("unexpected auth header: %s", auth)
}
if !strings.Contains(auth, "SignedHeaders=host;x-amz-content-sha256;x-amz-date") {
t.Fatalf("signed headers: %s", auth)
}
if req.Header.Get("x-amz-date") != "20260924T120000Z" {
t.Fatalf("amz date: %s", req.Header.Get("x-amz-date"))
}
}
func TestCanonicalQueryRFC3986(t *testing.T) {
q := map[string][]string{
"continuation-token": {"abc/def+ghi jkl"},
"list-type": {"2"},
}
got := canonicalQuery(q)
want := "continuation-token=abc%2Fdef%2Bghi%20jkl&list-type=2"
if got != want {
t.Fatalf("canonicalQuery = %q, want %q", got, want)
}
}
func TestS3StorePresign(t *testing.T) {
store, _ := newTestS3(t)
ctx := context.Background()
dl, err := store.Presign(ctx, "bucket-a", "docs/x.txt", http.MethodGet, 10*time.Minute)
if err != nil {
t.Fatalf("Presign: %v", err)
}
if dl.Method != http.MethodGet || dl.ExpiresAt.IsZero() {
t.Fatalf("bad presign: %+v", dl)
}
u, err := url.Parse(dl.URL)
if err != nil {
t.Fatalf("url parse: %v", err)
}
q := u.Query()
for _, k := range []string{"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-Expires", "X-Amz-SignedHeaders", "X-Amz-Signature"} {
if q.Get(k) == "" {
t.Fatalf("missing %s in %s", k, dl.URL)
}
}
if q.Get("X-Amz-Expires") != "600" || q.Get("X-Amz-SignedHeaders") != "host" {
t.Fatalf("bad query: %s", dl.URL)
}
if _, err := store.Presign(ctx, "b", "k", http.MethodDelete, 0); err == nil {
t.Fatal("expected method rejection")
}
// TTL cap.
capped, _ := store.Presign(ctx, "b", "k.txt", http.MethodPut, 48*time.Hour)
if cu, _ := url.Parse(capped.URL); cu.Query().Get("X-Amz-Expires") != "3600" {
t.Fatalf("ttl not capped: %s", capped.URL)
}
}
+31
View File
@@ -0,0 +1,31 @@
package storage
import (
"context"
"io"
"time"
)
// Store is the object-storage contract the platform service depends on.
// LocalStore keeps objects on disk; S3Store targets S3-compatible APIs.
type Store interface {
Put(ctx context.Context, bucketID, objectKey string, reader io.Reader) (PutResult, error)
Open(bucketID, objectKey string) (io.ReadCloser, string, error)
Delete(bucketID, objectKey string) error
MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error)
DeleteBucket(bucketID string) error
}
// PresignedURL is a short-lived direct-to-storage URL for one operation.
type PresignedURL struct {
URL string `json:"url"`
Method string `json:"method"`
ExpiresAt time.Time `json:"expires_at"`
}
// Presigner is an optional capability: stores backed by a real object API
// (S3) can mint URLs clients call directly. The local driver can't — its
// "URL" would have to be served by this backend anyway.
type Presigner interface {
Presign(ctx context.Context, bucketID, objectKey, method string, ttl time.Duration) (*PresignedURL, error)
}
+863 -4
View File
@@ -68,6 +68,22 @@ paths:
$ref: "#/components/schemas/MeResponse" $ref: "#/components/schemas/MeResponse"
"401": "401":
$ref: "#/components/responses/ErrorResponse" $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: /instance/public:
get: get:
tags: [Instance] tags: [Instance]
@@ -677,6 +693,37 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/BucketObject" $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}: /buckets/{bucketID}:
patch: patch:
tags: [Storage] tags: [Storage]
@@ -1300,6 +1347,37 @@ paths:
$ref: "#/components/schemas/TelemetryEventListResponse" $ref: "#/components/schemas/TelemetryEventListResponse"
"401": "401":
$ref: "#/components/responses/ErrorResponse" $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: /projects/{projectID}/issues:
get: get:
tags: [Telemetry] tags: [Telemetry]
@@ -1856,6 +1934,28 @@ paths:
description: text/event-stream of {type, occurred_at, data} messages description: text/event-stream of {type, occurred_at, data} messages
"401": "401":
$ref: "#/components/responses/ErrorResponse" $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: /projects/{projectID}/secrets:
get: get:
tags: [Secrets] tags: [Secrets]
@@ -1941,6 +2041,408 @@ paths:
$ref: "#/components/responses/ErrorResponse" $ref: "#/components/responses/ErrorResponse"
"404": "404":
$ref: "#/components/responses/ErrorResponse" $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: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
@@ -2290,6 +2792,34 @@ components:
items: items:
$ref: "#/components/schemas/OrganizationSummary" $ref: "#/components/schemas/OrganizationSummary"
required: [user, organizations] 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: OrganizationMembership:
type: object type: object
properties: properties:
@@ -2728,6 +3258,48 @@ components:
format: uuid format: uuid
nullable: true nullable: true
required: [objectKey, newObjectKey] 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: PaginationMetadata:
type: object type: object
properties: properties:
@@ -3512,7 +4084,7 @@ components:
type: array type: array
items: items:
type: string 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: enabled:
type: boolean type: boolean
has_secret: has_secret:
@@ -3534,7 +4106,7 @@ components:
type: array type: array
items: items:
type: string 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: enabled:
type: boolean type: boolean
required: [url] required: [url]
@@ -3551,7 +4123,7 @@ components:
type: array type: array
items: items:
type: string 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: enabled:
type: boolean type: boolean
WebhookCreateResponse: WebhookCreateResponse:
@@ -3656,6 +4228,11 @@ components:
description: Cron expression (5-field) or descriptor (@hourly, @every 30m). description: Cron expression (5-field) or descriptor (@hourly, @every 30m).
url: url:
type: string type: string
function_id:
type: string
format: uuid
nullable: true
description: When set, the schedule invokes this function instead of POSTing url.
payload: payload:
type: object type: object
description: Static JSON payload merged into every delivery's data field. description: Static JSON payload merged into every delivery's data field.
@@ -3691,6 +4268,11 @@ components:
url: url:
type: string type: string
format: uri 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: secret:
type: string type: string
description: HMAC key for X-Primora-Signature. Generated when omitted. description: HMAC key for X-Primora-Signature. Generated when omitted.
@@ -3700,7 +4282,7 @@ components:
enabled: enabled:
type: boolean type: boolean
default: true default: true
required: [name, schedule, url] required: [name, schedule]
UpdateScheduledJobRequest: UpdateScheduledJobRequest:
type: object type: object
properties: properties:
@@ -3712,6 +4294,11 @@ components:
url: url:
type: string type: string
format: uri 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: secret:
type: string type: string
description: Replace the signing secret. Omit to keep the current one. description: Replace the signing secret. Omit to keep the current one.
@@ -3821,3 +4408,275 @@ components:
value: value:
type: string type: string
required: [name, value] 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]
+8 -1
View File
@@ -21,7 +21,14 @@ project interactively.
## Commands ## Commands
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`, `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 ```sh
primora documents list --collection users \ primora documents list --collection users \
+79 -3
View File
@@ -3,7 +3,8 @@ import { cac } from "cac";
import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js"; import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
import { cmdAuditList } from "./commands/audit.js"; import { cmdAuditList } from "./commands/audit.js";
import { cmdDocumentsList } from "./commands/documents.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 { cmdContext, cmdUse } from "./commands/context.js";
import { cmdInject } from "./commands/inject.js"; import { cmdInject } from "./commands/inject.js";
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js"; import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
@@ -12,10 +13,11 @@ import {
cmdObjectsDownload, cmdObjectsDownload,
cmdObjectsList, cmdObjectsList,
cmdObjectsRemove, cmdObjectsRemove,
cmdObjectsPresign,
cmdObjectsUpload, cmdObjectsUpload,
} from "./commands/objects.js"; } from "./commands/objects.js";
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js"; import { cmdOrgsCreate, cmdOrgsList, cmdOrgsRemove } from "./commands/orgs.js";
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js"; import { cmdProjectsCreate, cmdProjectsList, cmdProjectsRemove } from "./commands/projects.js";
import { import {
cmdSecretsGet, cmdSecretsGet,
cmdSecretsImport, cmdSecretsImport,
@@ -50,6 +52,7 @@ cli
.option("--email <email>", "Account email") .option("--email <email>", "Account email")
.option("--password <password>", "Account password") .option("--password <password>", "Account password")
.option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session") .option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session")
.option("--json", "Machine-readable output")
.action(cmdLogin); .action(cmdLogin);
cli.command("logout", "Remove stored credentials").action(cmdLogout); cli.command("logout", "Remove stored credentials").action(cmdLogout);
@@ -68,6 +71,10 @@ cli
.command("orgs:create <name>", "Create an organization") .command("orgs:create <name>", "Create an organization")
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)") .option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.action(cmdOrgsCreate); .action(cmdOrgsCreate);
cli
.command("orgs:rm <org>", "Delete an organization (id, slug, or name)")
.option("--yes", "Skip the confirmation prompt")
.action(cmdOrgsRemove);
cli cli
.command("projects:list", "List projects in the current org") .command("projects:list", "List projects in the current org")
@@ -80,6 +87,10 @@ cli
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)") .option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.option("--description <text>", "Description") .option("--description <text>", "Description")
.action(cmdProjectsCreate); .action(cmdProjectsCreate);
cli
.command("projects:rm <project>", "Delete a project (id, slug, or name)")
.option("--yes", "Skip the confirmation prompt")
.action(cmdProjectsRemove);
cli cli
.command("buckets:list", "List buckets in the current project") .command("buckets:list", "List buckets in the current project")
@@ -92,6 +103,11 @@ cli
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)") .option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.option("--public", "Public visibility (default private)") .option("--public", "Public visibility (default private)")
.action(cmdBucketsCreate); .action(cmdBucketsCreate);
cli
.command("buckets:rm <bucket>", "Delete a bucket and its objects (id or slug)")
.option("--project <id>", "Project override")
.option("--yes", "Skip the confirmation prompt")
.action(cmdBucketsRemove);
cli cli
.command("objects:list <bucket>", "List objects in a bucket (id or slug)") .command("objects:list <bucket>", "List objects in a bucket (id or slug)")
@@ -114,6 +130,12 @@ cli
.command("objects:rm <bucket> <key>", "Delete an object") .command("objects:rm <bucket> <key>", "Delete an object")
.option("--project <id>", "Project override") .option("--project <id>", "Project override")
.action(cmdObjectsRemove); .action(cmdObjectsRemove);
cli
.command("objects:presign <bucket> <key>", "Mint a presigned URL (s3 driver)")
.option("--project <id>", "Project override")
.option("--upload", "PUT URL for direct upload instead of GET download")
.option("--ttl <seconds>", "Lifetime 60-3600 (default 900)")
.action(cmdObjectsPresign);
cli cli
.command("keys:list", "List API keys for the current project") .command("keys:list", "List API keys for the current project")
@@ -167,6 +189,12 @@ cli
.option("--offset <n>", "Page offset") .option("--offset <n>", "Page offset")
.action(cmdDocumentsList); .action(cmdDocumentsList);
cli
.command("events:send <type>", "Publish a custom.* event to realtime subscribers, webhooks, and functions")
.option("--project <id>", "Project override")
.option("--data <json>", "JSON object payload")
.action(cmdEventsSend);
cli cli
.command("audit:list", "List audit events for the current project") .command("audit:list", "List audit events for the current project")
.option("--project <id>", "Project override") .option("--project <id>", "Project override")
@@ -317,9 +345,57 @@ if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
argv[0] = "secrets:list"; argv[0] = "secrets:list";
} }
// mri coerces numeric-looking option values ("007" → 7, "" → 0), which
// corrupts secrets, keys, and passwords. Re-extract raw strings for every
// non-boolean flag and patch parsed options before the action runs.
function restoreRawOptionStrings(): void {
const cmd = (cli as unknown as { matchedCommand?: { options: { isBoolean: boolean; name: string; names: string[] }[] } })
.matchedCommand;
if (!cmd) return;
const state = cli as unknown as { options: Record<string, unknown>; globalCommand: { options: { isBoolean: boolean; name: string; names: string[] }[] } };
const specs = [...state.globalCommand.options, ...cmd.options].filter((o) => !o.isBoolean);
const raw = process.argv.slice(2);
const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
for (const spec of specs) {
if (typeof state.options[spec.name] !== "number") continue;
let value: string | undefined;
for (let i = 0; i < raw.length; i++) {
for (const n of spec.names) {
const flags = n.length === 1 ? [`-${n}`] : [`--${n}`, `--${kebab(n)}`];
for (const f of flags) {
if (raw[i] === f) value = raw[i + 1];
else if (raw[i].startsWith(f + "=")) value = raw[i].slice(f.length + 1);
}
}
}
if (value !== undefined) state.options[spec.name] = value;
}
}
// Unknown command or a bare group — cac only fires this on non-empty args.
cli.addEventListener("command:*", () => {
const state = cli as unknown as { args: string[]; commands: { name: string }[] };
const arg = String(state.args[0] ?? "");
const group = arg.split(":")[0];
const verbs = GROUPS.has(group)
? state.commands.map((c) => c.name).filter((n) => n.startsWith(group + ":")).map((n) => n.slice(group.length + 1))
: [];
process.stderr.write(`error: unknown command "${arg.replace(":", " ")}"\n`);
if (verbs.length) process.stderr.write(` ${group} verbs: ${verbs.join(", ")}\n`);
process.stderr.write(" run `primora --help` for usage\n");
process.exit(1);
});
async function main() { async function main() {
try { try {
cli.parse([process.argv[0], process.argv[1], ...argv], { run: false }); cli.parse([process.argv[0], process.argv[1], ...argv], { run: false });
restoreRawOptionStrings();
const state = cli as unknown as { matchedCommand?: unknown; options: Record<string, unknown> };
if (!state.matchedCommand && !state.options.help && !state.options.version) {
cli.outputHelp();
if (argv.length) process.exit(1);
return;
}
await cli.runMatchedCommand(); await cli.runMatchedCommand();
} catch (error) { } catch (error) {
fail(error); fail(error);
+37 -2
View File
@@ -10,6 +10,7 @@ interface LoginOptions {
email?: string; email?: string;
password?: string; password?: string;
apiKey?: string; apiKey?: string;
json?: boolean;
} }
function cancel(): never { function cancel(): never {
@@ -39,9 +40,22 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…"); throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…");
} }
cfg.auth = { type: "apiKey", key: options.apiKey }; cfg.auth = { type: "apiKey", key: options.apiKey };
// Validate the key against the instance before saving — a stored-but-
// dead key is a worse failure than a rejected login.
configureClient(cfg);
const ctx = await PlatformService.getActorContext();
if (ctx.organization) cfg.organizationId = ctx.organization.id;
if (ctx.project) cfg.projectId = ctx.project.id;
saveConfig(cfg); saveConfig(cfg);
success(`Saved API key credentials to ${configFilePath()}`); if (isJson(options)) {
dim("API keys are project-scoped — set context with `primora use --org <id> --project <id>`."); printJson(ctx);
return;
}
success(`API key verified — ${ctx.key_prefix}… (scopes: ${ctx.scopes?.join(", ") || "none"})`);
if (ctx.organization && ctx.project) {
success(`Context → ${ctx.organization.slug} / ${ctx.project.slug}`);
}
dim(`Config written to ${configFilePath()}`);
return; return;
} }
@@ -94,6 +108,10 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
configureClient(cfg); configureClient(cfg);
const me = await PlatformService.getMe(); const me = await PlatformService.getMe();
if (isJson(options)) {
printJson(me);
return;
}
success(`Signed in as ${me.user.email}`); success(`Signed in as ${me.user.email}`);
dim(`Config written to ${configFilePath()}`); dim(`Config written to ${configFilePath()}`);
} }
@@ -108,6 +126,23 @@ export async function cmdLogout(): Promise<void> {
export async function cmdWhoami(options: { json?: boolean }): Promise<void> { export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
const cfg = loadConfig(); const cfg = loadConfig();
configureClient(cfg); configureClient(cfg);
if (cfg.auth?.type === "apiKey") {
const ctx = await PlatformService.getActorContext();
if (isJson(options)) {
printJson(ctx);
return;
}
printTable(["KEY", "SCOPES", "ORGANIZATION", "PROJECT"], [
[
`${ctx.key_prefix}…`,
ctx.scopes?.join(", ") || "—",
ctx.organization?.slug ?? "—",
ctx.project?.slug ?? "—",
],
]);
dim(`\n${cfg.baseUrl} · auth: api_key`);
return;
}
const me = await PlatformService.getMe(); const me = await PlatformService.getMe();
if (isJson(options)) { if (isJson(options)) {
+21 -2
View File
@@ -1,8 +1,8 @@
import { CreateBucketRequest, StorageService } from "@primora/api-client"; import { CreateBucketRequest, StorageService } from "@primora/api-client";
import { loadConfig } from "../config.js"; import { loadConfig } from "../config.js";
import { configureClient, requireAuth, requireProject } from "../http.js"; import { configureClient, requireAuth, requireProject, resolveBucketId } from "../http.js";
import { isJson, printJson, printTable, success } from "../out.js"; import { confirmOrAbort, isJson, printJson, printTable, success } from "../out.js";
export async function cmdBucketsList(options: { export async function cmdBucketsList(options: {
project?: string; project?: string;
@@ -54,3 +54,22 @@ export async function cmdBucketsCreate(
} }
success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`); success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`);
} }
export async function cmdBucketsRemove(
bucketRef: string,
options: { project?: string; yes?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const bucketId = await resolveBucketId(projectId, bucketRef);
await confirmOrAbort(`Delete bucket "${bucketRef}" and all its objects?`, options.yes);
await StorageService.deleteBucket({ bucketId });
if (isJson(options)) {
printJson({ deleted: bucketId });
} else {
success(`deleted bucket ${bucketRef}`);
}
}
+29
View File
@@ -23,6 +23,22 @@ export async function cmdUse(options: UseOptions): Promise<void> {
requireAuth(cfg); requireAuth(cfg);
configureClient(cfg); configureClient(cfg);
// API keys are bound to one org+project — resolve the scope directly.
if (cfg.auth?.type === "apiKey") {
const ctx = await PlatformService.getActorContext();
if (options.org && ctx.organization && options.org !== ctx.organization.id && options.org !== ctx.organization.slug) {
throw new Error(`API key is scoped to ${ctx.organization.slug} — cannot switch to "${options.org}".`);
}
if (options.project && ctx.project && options.project !== ctx.project.id && options.project !== ctx.project.slug) {
throw new Error(`API key is scoped to ${ctx.project.slug} — cannot switch to "${options.project}".`);
}
if (ctx.organization) cfg.organizationId = ctx.organization.id;
if (ctx.project) cfg.projectId = ctx.project.id;
saveConfig(cfg);
success(`Context → ${ctx.organization?.slug ?? "—"} / ${ctx.project?.slug ?? "—"}`);
return;
}
const me = await PlatformService.getMe(); const me = await PlatformService.getMe();
const orgs = me.organizations; const orgs = me.organizations;
if (orgs.length === 0) { if (orgs.length === 0) {
@@ -77,6 +93,19 @@ export async function cmdContext(options: { json?: boolean }): Promise<void> {
requireAuth(cfg); requireAuth(cfg);
configureClient(cfg); configureClient(cfg);
if (cfg.auth?.type === "apiKey") {
const ctx = await PlatformService.getActorContext();
if (isJson(options)) {
printJson({ baseUrl: cfg.baseUrl, auth: "apiKey", ...ctx });
return;
}
dim(`url ${cfg.baseUrl}`);
dim(`auth api_key (${ctx.key_prefix}…, scopes: ${ctx.scopes?.join(", ") || "none"})`);
process.stdout.write(`org ${ctx.organization ? `${ctx.organization.name} (${ctx.organization.slug})` : "—"}\n`);
process.stdout.write(`project ${ctx.project ? `${ctx.project.name} (${ctx.project.slug})` : "—"}\n`);
return;
}
const me = await PlatformService.getMe(); const me = await PlatformService.getMe();
const org = me.organizations.find((o) => o.id === cfg.organizationId); const org = me.organizations.find((o) => o.id === cfg.organizationId);
const project = org?.projects.find((pr) => pr.id === cfg.projectId); const project = org?.projects.find((pr) => pr.id === cfg.projectId);
+48
View File
@@ -0,0 +1,48 @@
import { AutomationService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
import { isJson, printJson } from "../out.js";
interface EventsSendOptions {
project?: string;
data?: string;
json?: boolean;
}
export async function cmdEventsSend(type: string, options: EventsSendOptions): Promise<void> {
if (!type.startsWith("custom.")) {
throw new CliError(
`Invalid event type "${type}".`,
"Client events must live in the custom.* namespace — e.g. custom.deploy.done",
);
}
let data: Record<string, unknown> = {};
if (options.data) {
try {
const parsed = JSON.parse(options.data);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new CliError("--data must be a JSON object.", `Got: ${options.data.slice(0, 60)}`);
}
data = parsed;
} catch (e) {
if (e instanceof CliError) throw e;
throw new CliError("--data is not valid JSON.", (e as Error).message);
}
}
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const res = await AutomationService.publishProjectEvent({
projectId,
requestBody: { type, data },
});
if (isJson(options)) {
printJson(res);
return;
}
console.log(`Published ${res.published ?? type} — realtime subscribers, matching webhooks, and event_pattern functions fire.`);
}
+29 -1
View File
@@ -2,7 +2,7 @@ import { statSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises"; import { readFile, writeFile } from "node:fs/promises";
import { basename } from "node:path"; import { basename } from "node:path";
import { StorageService } from "@primora/api-client"; import { PresignObjectRequest, StorageService } from "@primora/api-client";
import { loadConfig } from "../config.js"; import { loadConfig } from "../config.js";
import { import {
@@ -133,3 +133,31 @@ export async function cmdObjectsRemove(
await StorageService.deleteBucketObject({ bucketId, objectKey }); await StorageService.deleteBucketObject({ bucketId, objectKey });
success(`Deleted ${objectKey}`); success(`Deleted ${objectKey}`);
} }
export async function cmdObjectsPresign(
bucketRef: string,
objectKey: string,
options: { project?: string; upload?: boolean; ttl?: string; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const bucketId = await bucketIdFor(cfg, bucketRef, options.project);
const presigned = await StorageService.presignBucketObject({
bucketId,
requestBody: {
key: objectKey,
op: options.upload
? PresignObjectRequest.op.UPLOAD
: PresignObjectRequest.op.DOWNLOAD,
ttl_seconds: options.ttl ? Number(options.ttl) : undefined,
},
});
if (isJson(options)) {
printJson(presigned);
return;
}
console.log(presigned.url);
console.error(`# ${presigned.method} — expires ${presigned.expires_at}`);
}
+28
View File
@@ -38,3 +38,31 @@ export async function cmdOrgsCreate(
} }
success(`Organization "${org.name}" created (${org.slug})`); success(`Organization "${org.name}" created (${org.slug})`);
} }
export async function cmdOrgsRemove(
orgRef: string,
options: { yes?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
let organizationId = orgRef;
if (!isUuid(orgRef)) {
const me = await PlatformService.getMe();
const found = me.organizations.find((o) => o.id === orgRef || o.slug === orgRef || o.name === orgRef);
if (!found) throw new Error(`Organization "${orgRef}" not found — pass its id directly.`);
organizationId = found.id;
}
await confirmOrAbort(`Delete organization "${orgRef}"? Every project inside goes with it.`, options.yes);
await OrganizationsService.deleteOrganization({ organizationId });
if (isJson(options)) {
printJson({ deleted: organizationId });
} else {
success(`deleted organization ${orgRef}`);
}
}
function isUuid(v: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}
+31
View File
@@ -50,3 +50,34 @@ export async function cmdProjectsCreate(
} }
success(`Project "${project.name}" created (${project.slug})`); success(`Project "${project.name}" created (${project.slug})`);
} }
export async function cmdProjectsRemove(
projectRef: string,
options: { org?: string; yes?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
let projectId = projectRef;
if (!isUuid(projectRef)) {
const me = await PlatformService.getMe();
const orgs = cfg.auth?.type === "apiKey" ? [] : me.organizations;
const found = orgs
.flatMap((o) => o.projects.map((pr) => ({ ...pr, orgId: o.id })))
.find((pr) => pr.id === projectRef || pr.slug === projectRef || pr.name === projectRef);
if (!found) throw new Error(`Project "${projectRef}" not found — pass its id directly.`);
projectId = found.id;
}
await confirmOrAbort(`Delete project "${projectRef}"? Buckets, secrets, and functions go with it.`, options.yes);
await ProjectsService.deleteProject({ projectId });
if (isJson(options)) {
printJson({ deleted: projectId });
} else {
success(`deleted project ${projectRef}`);
}
}
function isUuid(v: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}
+2
View File
@@ -60,6 +60,8 @@ export function configureClient(cfg: CliConfig): void {
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`; OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
OpenAPI.HEADERS = undefined; OpenAPI.HEADERS = undefined;
OpenAPI.TOKEN = undefined; OpenAPI.TOKEN = undefined;
// encodeURI leaves ?#& intact in path params — smuggles query segments.
OpenAPI.ENCODE_PATH = encodeURIComponent;
if (cfg.auth?.type === "apiKey") { if (cfg.auth?.type === "apiKey") {
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key }; OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
+19 -1
View File
@@ -17,9 +17,27 @@ export function dim(message: string): void {
process.stdout.write(pc.dim(message) + "\n"); process.stdout.write(pc.dim(message) + "\n");
} }
/** Destructive-action gate — requires --yes in scripts, prompts on a TTY. */
export async function confirmOrAbort(message: string, yes?: boolean): Promise<void> {
if (yes) return;
if (!process.stdin.isTTY) {
throw new Error(`${message} — pass --yes to confirm in non-interactive mode`);
}
const answer = await p.confirm({ message });
if (p.isCancel(answer) || !answer) {
p.cancel("Aborted.");
process.exit(1);
}
}
export function fail(error: unknown): never { export function fail(error: unknown): never {
const err = error as { message?: string; hint?: string; status?: number; body?: unknown }; const err = error as { message?: string; hint?: string; status?: number; body?: unknown; cause?: { code?: string } };
let message = err?.message ?? "unknown error"; let message = err?.message ?? "unknown error";
// undici TypeError "fetch failed" — the useful part is the cause code.
if (message === "fetch failed" || message.startsWith("fetch failed")) {
const code = err?.cause?.code;
message = `could not reach the instance${code ? ` (${code})` : ""} — check \`primora context\` and that it is running`;
}
// generated client throws ApiError with a parsed body // generated client throws ApiError with a parsed body
const body = err?.body as { error?: { message?: string } } | undefined; const body = err?.body as { error?: { message?: string } } | undefined;
if (body?.error?.message) message = body.error.message; if (body?.error?.message) message = body.error.message;
+18
View File
@@ -44,3 +44,21 @@ file, one crypto implementation — terminal and desktop always agree. Requires
Injection and the credential broker stay CLI-only (`primora inject`, Injection and the credential broker stay CLI-only (`primora inject`,
`primora agent`). `primora agent`).
- Deep links / push notifications: deliberately omitted — nothing needs them yet. - Deep links / push notifications: deliberately omitted — nothing needs them yet.
## Auto-updates
`tauri-plugin-updater` checks `releases/latest/download/latest.json` on the
GitHub repo when the connect screen loads; if a newer signed release exists
it offers "Update & restart" — download, minisign verification, install,
relaunch. No update manifest or signature failure = silent no-op; unsigned
artifacts can never install.
Release side (already wired in `.github/workflows/release.yml`): the desktop
job signs updater artifacts with `TAURI_SIGNING_PRIVATE_KEY` (+ optional
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`) repo secrets, uploads the `.sig`
files, and the release job assembles `latest.json` from them. Without the
secret the build still succeeds but emits no signatures — and no manifest.
The matching public key is embedded in `tauri.conf.json`. The private key
lives only in repo secrets and on the maintainer's machine — regenerate with
`npx tauri signer generate` and update both if it is ever lost.
+457 -8
View File
@@ -47,6 +47,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -506,6 +515,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -529,7 +548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types",
"libc", "libc",
@@ -542,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation", "core-foundation 0.10.1",
"libc", "libc",
] ]
@@ -713,6 +732,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -996,6 +1026,16 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.12" version = "0.1.12"
@@ -1254,6 +1294,17 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@@ -1530,6 +1581,21 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@@ -1548,9 +1614,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1847,6 +1915,36 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.20",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -2036,6 +2134,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2288,6 +2392,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2", "block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2303,6 +2408,18 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2366,6 +2483,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -2382,6 +2505,20 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -2615,7 +2752,9 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-process",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater",
] ]
[[package]] [[package]]
@@ -2804,15 +2943,20 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -2824,6 +2968,20 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -2852,6 +3010,79 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -2867,6 +3098,15 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -2924,6 +3164,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.36.1" version = "0.36.1"
@@ -3145,6 +3408,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@@ -3257,6 +3536,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.8" version = "1.0.8"
@@ -3320,6 +3605,27 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3341,7 +3647,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2", "block2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dbus", "dbus",
@@ -3351,7 +3657,7 @@ dependencies = [
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",
"gtk", "gtk",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -3384,6 +3690,17 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -3408,7 +3725,7 @@ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"image", "image",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"mime", "mime",
@@ -3504,6 +3821,32 @@ dependencies = [
"tauri-utils", "tauri-utils",
] ]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]] [[package]]
name = "tauri-plugin-single-instance" name = "tauri-plugin-single-instance"
version = "2.4.4" version = "2.4.4"
@@ -3520,6 +3863,39 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-updater"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.20",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
@@ -3530,7 +3906,7 @@ dependencies = [
"dpi", "dpi",
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"objc2", "objc2",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
@@ -3553,7 +3929,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -3742,6 +4118,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.19" version = "0.7.19"
@@ -4055,6 +4441,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4298,6 +4690,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -4483,6 +4884,17 @@ dependencies = [
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@@ -4528,6 +4940,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -4823,7 +5244,7 @@ dependencies = [
"gtk", "gtk",
"http", "http",
"javascriptcore-rs", "javascriptcore-rs",
"jni", "jni 0.21.1",
"libc", "libc",
"ndk", "ndk",
"objc2", "objc2",
@@ -4870,6 +5291,16 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
@@ -4984,6 +5415,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.5" version = "0.2.5"
@@ -5017,6 +5454,18 @@ dependencies = [
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.2",
"memchr",
]
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.8" version = "0.6.8"
+2
View File
@@ -15,5 +15,7 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -3,5 +3,5 @@
"identifier": "default", "identifier": "default",
"description": "Default capabilities for the main window", "description": "Default capabilities for the main window",
"windows": ["main", "vault"], "windows": ["main", "vault"],
"permissions": ["core:default"] "permissions": ["core:default", "updater:default", "process:default"]
} }
+2
View File
@@ -200,6 +200,8 @@ pub fn run() {
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
show_main(app); show_main(app);
})) }))
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_server_url, get_server_url,
set_server_url, set_server_url,
+9
View File
@@ -23,9 +23,18 @@
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http:; img-src 'self' data:; style-src 'self'" "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http:; img-src 'self' data:; style-src 'self'"
} }
}, },
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVERjFDMzI1MURGMTlBNDAKUldSQW12RWRKY1B4WFpQN2N0RjdCemllN1ptcDhObFJQMVdHL3pLTXMzNEN6TlJoMEZhQUhDNG4K",
"endpoints": [
"https://github.com/Dvorinka/Primora/releases/latest/download/latest.json"
]
}
},
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"createUpdaterArtifacts": true,
"category": "DeveloperTool", "category": "DeveloperTool",
"shortDescription": "Primora desktop client", "shortDescription": "Primora desktop client",
"longDescription": "Thin desktop shell for a self-hosted Primora deployment. Renders the console served by your own server.", "longDescription": "Thin desktop shell for a self-hosted Primora deployment. Renders the console served by your own server.",
+47 -3
View File
@@ -28,6 +28,10 @@
<p id="err" class="err" hidden></p> <p id="err" class="err" hidden></p>
<p class="hint">The desktop app renders the console served by your own server. Nothing is hosted by us.</p> <p class="hint">The desktop app renders the console served by your own server. Nothing is hosted by us.</p>
<p class="hint"><a href="#" id="vault-link" class="vlink">Open the local Secrets Vault</a></p> <p class="hint"><a href="#" id="vault-link" class="vlink">Open the local Secrets Vault</a></p>
<div id="update" hidden>
<p class="hint" id="update-text"></p>
<button type="button" id="update-install">Update &amp; restart</button>
</div>
</main> </main>
<script type="module"> <script type="module">
@@ -44,12 +48,52 @@
button.textContent = "Connect"; button.textContent = "Connect";
}; };
// Updater — check before auto-connecting: a saved URL redirects
// immediately, so the check must resolve first or it never shows.
// Bounded at 5s; failures are silent (dev builds, offline, no
// manifest). Unsigned artifacts can never install — minisign is
// enforced by the plugin against the pubkey baked into the bundle.
const updateP = (tauri?.updater && tauri?.process)
? Promise.race([
tauri.updater.check().catch(() => null),
new Promise((r) => setTimeout(() => r(null), 5000)),
])
: Promise.resolve(null);
if (!tauri) { if (!tauri) {
fail("This page is meant to run inside the Primora desktop app."); fail("This page is meant to run inside the Primora desktop app.");
} else { } else {
// A saved deployment URL means "skip the form" on every launch. // A saved deployment URL means "skip the form" on every launch —
const saved = await tauri.core.invoke("get_server_url").catch(() => null); // unless an update is waiting, which wins the redirect.
if (saved) location.replace(saved); const update = await updateP;
if (update) {
const box = document.getElementById("update");
const text = document.getElementById("update-text");
const install = document.getElementById("update-install");
text.textContent = `Update available — v${update.version} (you have ${update.currentVersion}).`;
box.hidden = false;
install.addEventListener("click", async () => {
install.disabled = true;
install.textContent = "Downloading…";
try {
await update.downloadAndInstall();
await tauri.process.relaunch();
} catch (e) {
install.disabled = false;
install.textContent = "Update & restart";
text.textContent = `Update failed: ${e}`;
}
});
// "Not now" continues to the saved server.
const later = document.createElement("button");
later.type = "button";
later.textContent = "Not now";
later.style.marginLeft = "0.5rem";
later.addEventListener("click", () => { const saved = _savedUrl; if (saved) location.replace(saved); });
box.appendChild(later);
}
const _savedUrl = await tauri.core.invoke("get_server_url").catch(() => null);
if (_savedUrl && !update) location.replace(_savedUrl);
} }
document.getElementById("vault-link").addEventListener("click", (e) => { document.getElementById("vault-link").addEventListener("click", (e) => {
+10 -3
View File
@@ -27,13 +27,20 @@ RUN npm run build
# Stage 2: Serve with Nginx # Stage 2: Serve with Nginx
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
COPY --from=builder /app/apps/frontend/dist /usr/share/nginx/html COPY --from=builder /app/apps/frontend/dist /usr/share/nginx/html
# Add simple nginx config for SPA routing # Add simple nginx config for SPA routing. index.html must always revalidate —
# it references hashed assets that change every deploy. Hashed /assets files are
# immutable and safe to cache long-term.
RUN echo 'server { \ RUN echo 'server { \
listen 80; \ listen 80; \
root /usr/share/nginx/html; \
index index.html; \
location /assets/ { \
try_files $uri =404; \
add_header Cache-Control "public, max-age=31536000, immutable"; \
} \
location / { \ location / { \
root /usr/share/nginx/html; \
index index.html; \
try_files $uri $uri/ /index.html; \ try_files $uri $uri/ /index.html; \
add_header Cache-Control "no-cache, must-revalidate"; \
} \ } \
}' > /etc/nginx/conf.d/default.conf }' > /etc/nginx/conf.d/default.conf
-7
View File
@@ -16,13 +16,6 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Primora" /> <meta name="apple-mobile-web-app-title" content="Primora" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<style> <style>
html { background: #0a0a0b; color-scheme: dark; } html { background: #0a0a0b; color-scheme: dark; }
html.light { background: #fafaf9; color-scheme: light; } html.light { background: #fafaf9; color-scheme: light; }
+2
View File
@@ -10,6 +10,8 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@fontsource/geist": "^5.3.0",
"@fontsource/geist-mono": "^5.3.0",
"@primora/api-client": "0.5.0", "@primora/api-client": "0.5.0",
"@primora/shared-types": "0.5.0", "@primora/shared-types": "0.5.0",
"better-auth": "^1.5.6", "better-auth": "^1.5.6",
+15 -6
View File
@@ -26,7 +26,7 @@ import {
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"; import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js";
import { authClient, fetchApiToken } from "./lib/auth-client"; import { authClient, fetchApiToken } from "./lib/auth-client";
import { configureApiToken } from "./lib/api"; import { configureApiToken, errorMessage } from "./lib/api";
import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode"; import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode";
import { import {
AppShell, AppShell,
@@ -48,6 +48,7 @@ import {
TelemetryPage, TelemetryPage,
IntegrationsPage, IntegrationsPage,
VaultPage, VaultPage,
FunctionsPage,
LoginPage, LoginPage,
MembersPage, MembersPage,
ProjectsPage, ProjectsPage,
@@ -63,6 +64,7 @@ import {
IconDatabases, IconDatabases,
IconTelemetry, IconTelemetry,
IconZap, IconZap,
IconTerminal,
IconCollections, IconCollections,
IconAudit, IconAudit,
IconAuth, IconAuth,
@@ -82,11 +84,7 @@ type ObjectPreview =
| { kind: "unsupported"; message: string }; | { kind: "unsupported"; message: string };
function getErrorMessage(error: unknown, fallback: string) { function getErrorMessage(error: unknown, fallback: string) {
if (error && typeof error === "object" && "message" in error) { return errorMessage(error, fallback);
const message = (error as { message?: unknown }).message;
if (typeof message === "string" && message.length > 0) return message;
}
return fallback;
} }
function formatDate(value?: string | null) { function formatDate(value?: string | null) {
@@ -1533,6 +1531,7 @@ export default function App() {
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") }, { id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
{ id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: <IconZap class="w-4 h-4" />, keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") }, { id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: <IconZap class="w-4 h-4" />, keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") },
{ id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: <IconKey class="w-4 h-4" />, keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") }, { id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: <IconKey class="w-4 h-4" />, keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") },
{ id: "nav-functions", label: "Go to Functions", category: "Navigate", icon: <IconTerminal class="w-4 h-4" />, keywords: ["serverless", "code", "lambda", "invoke", "edge"], action: () => setActiveView("functions") },
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") }, { id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") }, { id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") }, { id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
@@ -1722,6 +1721,8 @@ export default function App() {
<Show when={activeView() === "members"}> <Show when={activeView() === "members"}>
<MembersPage <MembersPage
projectID={activeProject()?.id}
demoMode={isDemo}
organizationMembers={organizationMembers()} organizationMembers={organizationMembers()}
organizationInvitations={organizationInvitations()} organizationInvitations={organizationInvitations()}
projectMembers={projectMembers()} projectMembers={projectMembers()}
@@ -1782,6 +1783,14 @@ export default function App() {
/> />
</Show> </Show>
<Show when={activeView() === "functions"}>
<FunctionsPage
projectID={activeProject()?.id}
canManage={canUpdateProject()}
demoMode={isDemo}
/>
</Show>
<Show when={activeView() === "storage"}> <Show when={activeView() === "storage"}>
<StoragePage <StoragePage
buckets={buckets()} buckets={buckets()}
@@ -11,6 +11,7 @@ import {
IconTelemetry, IconTelemetry,
IconIntegrations, IconIntegrations,
IconZap, IconZap,
IconTerminal,
IconKey, IconKey,
IconAudit, IconAudit,
IconAuth, IconAuth,
@@ -33,6 +34,7 @@ export type ViewType =
| "telemetry" | "telemetry"
| "integrations" | "integrations"
| "automation" | "automation"
| "functions"
| "vault" | "vault"
| "auth" | "auth"
| "audit" | "audit"
@@ -51,6 +53,7 @@ const projectNav: NavEntry[] = [
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry }, { id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations }, { id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap }, { id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
{ id: "functions", label: "Functions", shortLabel: "Funcs", icon: IconTerminal },
{ id: "vault", label: "Vault", icon: IconKey }, { id: "vault", label: "Vault", icon: IconKey },
{ id: "storage", label: "Storage", icon: IconStorage }, { id: "storage", label: "Storage", icon: IconStorage },
{ id: "collections", label: "Collections", icon: IconCollections }, { id: "collections", label: "Collections", icon: IconCollections },
@@ -99,6 +102,7 @@ const viewTitle: Record<ViewType, string> = {
telemetry: "Telemetry", telemetry: "Telemetry",
integrations: "Integrations", integrations: "Integrations",
automation: "Automation", automation: "Automation",
functions: "Functions",
vault: "Vault", vault: "Vault",
auth: "Authentication", auth: "Authentication",
audit: "Audit log", audit: "Audit log",
+17 -6
View File
@@ -1,4 +1,4 @@
import { type JSX, Show, createEffect, onCleanup, splitProps } from "solid-js"; import { type JSX, Show, createEffect, createUniqueId, onCleanup, splitProps } from "solid-js";
import { Portal } from "solid-js/web"; import { Portal } from "solid-js/web";
interface ModalProps extends JSX.HTMLAttributes<HTMLDivElement> { interface ModalProps extends JSX.HTMLAttributes<HTMLDivElement> {
@@ -6,6 +6,7 @@ interface ModalProps extends JSX.HTMLAttributes<HTMLDivElement> {
onClose: () => void; onClose: () => void;
title?: string; title?: string;
description?: string; description?: string;
error?: JSX.Element;
size?: "sm" | "md" | "lg" | "xl" | "full"; size?: "sm" | "md" | "lg" | "xl" | "full";
closeOnEscape?: boolean; closeOnEscape?: boolean;
closeOnBackdrop?: boolean; closeOnBackdrop?: boolean;
@@ -26,6 +27,7 @@ export function Modal(props: ModalProps) {
"onClose", "onClose",
"title", "title",
"description", "description",
"error",
"size", "size",
"closeOnEscape", "closeOnEscape",
"closeOnBackdrop", "closeOnBackdrop",
@@ -38,6 +40,8 @@ export function Modal(props: ModalProps) {
const closeOnEscape = () => local.closeOnEscape ?? true; const closeOnEscape = () => local.closeOnEscape ?? true;
const closeOnBackdrop = () => local.closeOnBackdrop ?? true; const closeOnBackdrop = () => local.closeOnBackdrop ?? true;
const showClose = () => local.showClose ?? true; const showClose = () => local.showClose ?? true;
const titleId = createUniqueId();
const descriptionId = createUniqueId();
createEffect(() => { createEffect(() => {
if (local.open) { if (local.open) {
@@ -71,8 +75,8 @@ export function Modal(props: ModalProps) {
class="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in" class="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={local.title ? "modal-title" : undefined} aria-labelledby={local.title ? titleId : undefined}
aria-describedby={local.description ? "modal-description" : undefined} aria-describedby={local.description ? descriptionId : undefined}
> >
{/* Backdrop */} {/* Backdrop */}
<div <div
@@ -91,12 +95,12 @@ export function Modal(props: ModalProps) {
<div class="flex items-start justify-between p-6 border-b border-border"> <div class="flex items-start justify-between p-6 border-b border-border">
<div class="flex-1"> <div class="flex-1">
<Show when={local.title}> <Show when={local.title}>
<h2 id="modal-title" class="text-xl font-semibold text-text-1"> <h2 id={titleId} class="text-xl font-semibold text-text-1">
{local.title} {local.title}
</h2> </h2>
</Show> </Show>
<Show when={local.description}> <Show when={local.description}>
<p id="modal-description" class="mt-1.5 text-sm text-text-2"> <p id={descriptionId} class="mt-1.5 text-sm text-text-2">
{local.description} {local.description}
</p> </p>
</Show> </Show>
@@ -116,7 +120,14 @@ export function Modal(props: ModalProps) {
</Show> </Show>
{/* Body */} {/* Body */}
<div class="p-6">{local.children}</div> <div class="p-6">
<Show when={local.error}>
<div class="message message-error mb-4" role="alert">
{local.error}
</div>
</Show>
{local.children}
</div>
</div> </div>
</div> </div>
</Portal> </Portal>
+9
View File
@@ -5,6 +5,15 @@
hairlines, tabular numerals, no decoration. hairlines, tabular numerals, no decoration.
============================================================ */ ============================================================ */
/* Vendored via @fontsource — self-hosted, no runtime Google dependency
(the nginx CSP only allows 'self'). */
@import "@fontsource/geist/400.css";
@import "@fontsource/geist/500.css";
@import "@fontsource/geist/600.css";
@import "@fontsource/geist/700.css";
@import "@fontsource/geist-mono/400.css";
@import "@fontsource/geist-mono/500.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { errorMessage } from "../api";
describe("errorMessage", () => {
it("prefers the server's reason over the HTTP status text", () => {
const apiError = {
message: "Bad Request",
body: { error: { message: "webhook url must be https (http allowed only for private/self-hosted targets)" } },
};
expect(errorMessage(apiError)).toBe(
"webhook url must be https (http allowed only for private/self-hosted targets)",
);
});
it("falls back to error.message when no body reason exists", () => {
expect(errorMessage(new Error("network down"))).toBe("network down");
});
it("uses the fallback for non-error values", () => {
expect(errorMessage(null, "Nope")).toBe("Nope");
expect(errorMessage(undefined)).toBe("Request failed");
expect(errorMessage("string failure")).toBe("Request failed");
});
it("ignores empty body and message strings", () => {
expect(errorMessage({ message: "", body: { error: { message: "" } } }, "Empty")).toBe("Empty");
});
});
+19
View File
@@ -3,6 +3,8 @@ import { OpenAPI } from "@primora/api-client";
import { fetchApiToken } from "./auth-client"; import { fetchApiToken } from "./auth-client";
OpenAPI.BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1"; OpenAPI.BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
// encodeURI leaves ?#& intact in path params — smuggles query segments.
OpenAPI.ENCODE_PATH = encodeURIComponent;
OpenAPI.CREDENTIALS = "include"; OpenAPI.CREDENTIALS = "include";
OpenAPI.WITH_CREDENTIALS = true; OpenAPI.WITH_CREDENTIALS = true;
OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? ""; OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? "";
@@ -10,3 +12,20 @@ OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? "";
export function configureApiToken(tokenResolver?: () => Promise<string | undefined>) { export function configureApiToken(tokenResolver?: () => Promise<string | undefined>) {
OpenAPI.TOKEN = tokenResolver ? async () => (await tokenResolver()) ?? "" : async () => (await fetchApiToken()) ?? ""; OpenAPI.TOKEN = tokenResolver ? async () => (await tokenResolver()) ?? "" : async () => (await fetchApiToken()) ?? "";
} }
/**
* Extracts a human-readable message from an API failure.
* ApiError.message is only the HTTP status text ("Bad Request") — the backend
* sends the actionable reason in body.error.message. Prefer it.
*/
export function errorMessage(error: unknown, fallback = "Request failed"): string {
if (error && typeof error === "object") {
const body = (error as { body?: { error?: { message?: unknown } } }).body;
if (typeof body?.error?.message === "string" && body.error.message.length > 0) {
return body.error.message;
}
const message = (error as { message?: unknown }).message;
if (typeof message === "string" && message.length > 0) return message;
}
return fallback;
}
+41 -3
View File
@@ -21,25 +21,63 @@ const PAGE_SIZE = 25;
const ACTION_OPTIONS = [ const ACTION_OPTIONS = [
"", "",
"platform.bootstrap",
"organization.created",
"organization.updated",
"organization.deleted",
"organization.member.role_updated",
"organization.member.removed",
"project.created", "project.created",
"project.updated", "project.updated",
"project.deleted", "project.deleted",
"project.member.role_updated",
"project.member.removed",
"invitation.created",
"invitation.accepted",
"invitation.revoked",
"bucket.created", "bucket.created",
"bucket.updated", "bucket.updated",
"bucket.deleted", "bucket.deleted",
"object.uploaded", "object.uploaded",
"object.updated", "object.updated",
"object.deleted", "object.deleted",
"object.copied",
"object.moved",
"api_key.created", "api_key.created",
"api_key.revoked", "api_key.revoked",
"member.invited",
"member.role_updated",
"member.removed",
"collection.created", "collection.created",
"collection.updated",
"collection.deleted", "collection.deleted",
"document.created", "document.created",
"document.updated", "document.updated",
"document.deleted", "document.deleted",
"db_connection.created",
"db_connection.deleted",
"db_connection.transferred",
"secret.set",
"secret.revealed",
"secret.deleted",
"function.created",
"function.updated",
"function.deleted",
"function.invoked",
"job.created",
"job.updated",
"job.deleted",
"job.run",
"webhook.created",
"webhook.updated",
"webhook.deleted",
"webhook.tested",
"inbound_hook.created",
"inbound_hook.deleted",
"integration.created",
"integration.deleted",
"integration.tested",
"alert.created",
"alert.updated",
"alert.deleted",
"deploy.marker",
]; ];
function actionVariant(action: string): "success" | "error" | "primary" | "warning" | "neutral" { function actionVariant(action: string): "success" | "error" | "primary" | "warning" | "neutral" {
+67 -16
View File
@@ -1,15 +1,18 @@
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"; import { For, Show, createEffect, createSignal, onCleanup } from "solid-js";
import { import {
AutomationService, AutomationService,
FunctionsService,
OpenAPI, OpenAPI,
type ScheduledJob, type ScheduledJob,
type ScheduledJobRun, type ScheduledJobRun,
type Function as PrimoraFunction,
} from "@primora/api-client"; } from "@primora/api-client";
import { fetchApiToken } from "../lib/auth-client"; import { fetchApiToken } from "../lib/auth-client";
import { demoService } from "../lib/demo-mode"; import { demoService } from "../lib/demo-mode";
import { Badge } from "../components/Badge"; import { Badge } from "../components/Badge";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { Input, Textarea } from "../components/Input"; import { Input, Textarea } from "../components/Input";
import { errorMessage } from "../lib/api";
import { import {
IconPlus, IconPlus,
IconRefresh, IconRefresh,
@@ -35,7 +38,7 @@ interface RealtimeEvent {
data: Record<string, unknown>; data: Record<string, unknown>;
} }
const err = (e: unknown) => (e instanceof Error ? e.message : String(e)); const err = (e: unknown) => errorMessage(e, String(e));
const svc = (demo: boolean) => const svc = (demo: boolean) =>
(demo ? (demoService as unknown as typeof AutomationService) : AutomationService); (demo ? (demoService as unknown as typeof AutomationService) : AutomationService);
@@ -63,7 +66,8 @@ export function AutomationPage(props: AutomationPageProps) {
const [runs, setRuns] = createSignal<Record<string, ScheduledJobRun[]>>({}); const [runs, setRuns] = createSignal<Record<string, ScheduledJobRun[]>>({});
const [expandedJob, setExpandedJob] = createSignal<string>(); const [expandedJob, setExpandedJob] = createSignal<string>();
const [showCreate, setShowCreate] = createSignal(false); const [showCreate, setShowCreate] = createSignal(false);
const [jobForm, setJobForm] = createSignal({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true }); const [jobForm, setJobForm] = createSignal({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true, target: "url" as "url" | "function", function_id: "" });
const [functions, setFunctions] = createSignal<PrimoraFunction[]>([]);
const [createdSecret, setCreatedSecret] = createSignal(""); const [createdSecret, setCreatedSecret] = createSignal("");
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
const [busy, setBusy] = createSignal<string>(); const [busy, setBusy] = createSignal<string>();
@@ -82,6 +86,10 @@ export function AutomationPage(props: AutomationPageProps) {
try { try {
const res = await svc(props.demoMode).listScheduledJobs({ projectId: props.projectID }); const res = await svc(props.demoMode).listScheduledJobs({ projectId: props.projectID });
setJobs(res.items ?? []); setJobs(res.items ?? []);
if (!props.demoMode) {
const fr = await FunctionsService.listFunctions({ projectId: props.projectID });
setFunctions(fr.items ?? []);
}
} catch (e) { } catch (e) {
setError(err(e)); setError(err(e));
} finally { } finally {
@@ -199,14 +207,15 @@ export function AutomationPage(props: AutomationPageProps) {
requestBody: { requestBody: {
name: form.name.trim(), name: form.name.trim(),
schedule: form.schedule.trim(), schedule: form.schedule.trim(),
url: form.url.trim(), url: form.target === "url" ? form.url.trim() : undefined,
function_id: form.target === "function" ? form.function_id : undefined,
secret: form.secret.trim() || undefined, secret: form.secret.trim() || undefined,
payload, payload,
enabled: form.enabled, enabled: form.enabled,
}, },
}); });
if (created.secret) setCreatedSecret(created.secret); if (created.secret) setCreatedSecret(created.secret);
setJobForm({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true }); setJobForm({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true, target: "url", function_id: "" });
setShowCreate(false); setShowCreate(false);
}, "Schedule created"); }, "Schedule created");
}; };
@@ -335,7 +344,9 @@ export function AutomationPage(props: AutomationPageProps) {
<code class="text-xs">{job.schedule}</code> <code class="text-xs">{job.schedule}</code>
</td> </td>
<td class="text-text-2" style="max-width:14rem;overflow:hidden;text-overflow:ellipsis"> <td class="text-text-2" style="max-width:14rem;overflow:hidden;text-overflow:ellipsis">
{job.url} {job.function_id
? `ƒ ${functions().find((f) => f.id === job.function_id)?.name ?? "function"}`
: job.url}
</td> </td>
<td class="hidden md:table-cell text-text-2">{job.enabled ? fmtTime(job.next_run_at) : "—"}</td> <td class="hidden md:table-cell text-text-2">{job.enabled ? fmtTime(job.next_run_at) : "—"}</td>
<td class="hidden md:table-cell"> <td class="hidden md:table-cell">
@@ -522,7 +533,7 @@ export function AutomationPage(props: AutomationPageProps) {
</div> </div>
</Show> </Show>
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New schedule" size="md"> <Modal error={error()} open={showCreate()} onClose={() => setShowCreate(false)} title="New schedule" size="md">
<form onSubmit={createJob} class="space-y-4"> <form onSubmit={createJob} class="space-y-4">
<Input <Input
label="Name" label="Name"
@@ -545,16 +556,56 @@ export function AutomationPage(props: AutomationPageProps) {
</p> </p>
</div> </div>
<div> <div>
<Input <span class="label">Target</span>
label="Target URL" <div class="flex gap-2 mb-2">
value={jobForm().url} <button
onInput={(e) => setJobForm((c) => ({ ...c, url: e.currentTarget.value }))} type="button"
placeholder="https://api.example.com/jobs/rollup" class={`btn btn-sm ${jobForm().target === "url" ? "btn-primary" : "btn-secondary"}`}
required onClick={() => setJobForm((c) => ({ ...c, target: "url" }))}
/> >
<p class="label-hint"> Webhook URL
HTTPS for public endpoints; plain HTTP allowed for private/self-hosted targets. </button>
</p> <button
type="button"
class={`btn btn-sm ${jobForm().target === "function" ? "btn-primary" : "btn-secondary"}`}
onClick={() => setJobForm((c) => ({ ...c, target: "function" }))}
>
Function
</button>
</div>
<Show
when={jobForm().target === "function"}
fallback={
<>
<Input
label="Target URL"
value={jobForm().url}
onInput={(e) => setJobForm((c) => ({ ...c, url: e.currentTarget.value }))}
placeholder="https://api.example.com/jobs/rollup"
required
/>
<p class="label-hint">
HTTPS for public endpoints; plain HTTP allowed for private/self-hosted targets.
</p>
</>
}
>
<select
class="input"
value={jobForm().function_id}
onChange={(e) => setJobForm((c) => ({ ...c, function_id: e.currentTarget.value }))}
required
>
<option value="">Select a function…</option>
<For each={functions()}>
{(f) => <option value={f.id}>{f.name} ({f.runtime})</option>}
</For>
</select>
<p class="label-hint">
The function receives the resolved payload as JSON. Runs appear under the function's
history.
</p>
</Show>
</div> </div>
<Textarea <Textarea
label="Payload (optional JSON)" label="Payload (optional JSON)"
+4 -1
View File
@@ -13,6 +13,7 @@ import { demoService } from "../lib/demo-mode";
import { Badge } from "../components/Badge"; import { Badge } from "../components/Badge";
import { Modal, ModalFooter } from "../components/Modal"; import { Modal, ModalFooter } from "../components/Modal";
import { Input, Select, Textarea } from "../components/Input"; import { Input, Select, Textarea } from "../components/Input";
import { errorMessage } from "../lib/api";
import { import {
IconChevronRight, IconChevronRight,
IconDatabases, IconDatabases,
@@ -57,7 +58,7 @@ const emptyForm: ConnForm = {
const dbTypes = Object.values(CreateDBConnectionRequest.db_type); const dbTypes = Object.values(CreateDBConnectionRequest.db_type);
const sqlish = (t?: string) => t !== "redis"; const sqlish = (t?: string) => t !== "redis";
const err = (e: unknown) => (e instanceof Error ? e.message : String(e)); const err = (e: unknown) => errorMessage(e, String(e));
/* the demo service mirrors the generated DatabasesService call shapes */ /* the demo service mirrors the generated DatabasesService call shapes */
const svc = (demo: boolean) => const svc = (demo: boolean) =>
@@ -933,6 +934,7 @@ export function DatabasesPage(props: DatabasesPageProps) {
<Modal <Modal
open={addOpen()} open={addOpen()}
onClose={() => setAddOpen(false)} onClose={() => setAddOpen(false)}
error={error()}
title="Add connection" title="Add connection"
description="Credentials are stored server-side and never sent back to the browser." description="Credentials are stored server-side and never sent back to the browser."
> >
@@ -1024,6 +1026,7 @@ export function DatabasesPage(props: DatabasesPageProps) {
<Modal <Modal
open={!!confirmDelete()} open={!!confirmDelete()}
onClose={() => setConfirmDelete(undefined)} onClose={() => setConfirmDelete(undefined)}
error={error()}
title="Delete connection" title="Delete connection"
size="sm" size="sm"
> >
+451
View File
@@ -0,0 +1,451 @@
import { For, Show, createEffect, createSignal } from "solid-js";
import {
ApiError,
CreateFunctionRequest,
Function,
FunctionsService,
type FunctionRun,
} from "@primora/api-client";
import { Modal, ModalFooter } from "../components/Modal";
import { Input, Select, Textarea } from "../components/Input";
import { Badge } from "../components/Badge";
import { IconPlus, IconTrash, IconPlay, IconTerminal } from "../components/Icons";
import { errorMessage } from "../lib/api";
interface FunctionsPageProps {
projectID?: string;
canManage: boolean;
demoMode: boolean;
}
const err = (e: unknown) => errorMessage(e, String(e));
const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—");
const STARTER_CODE = `// The payload arrives on stdin and as PRIMORA_PAYLOAD.
const input = JSON.parse(await new Response(Bun.stdin.stream()).text());
console.log(JSON.stringify({ echo: input }));
`;
const DEMO_FUNCTIONS: Function[] = [
{ id: "f1", name: "resize-image", runtime: Function.runtime.BUN, enabled: true, created_at: "", updated_at: "2026-09-20T09:00:00Z" },
{ id: "f2", name: "sync-billing", runtime: Function.runtime.BUN, enabled: false, created_at: "", updated_at: "2026-09-15T14:00:00Z" },
];
export function FunctionsPage(props: FunctionsPageProps) {
const [functions, setFunctions] = createSignal<Function[]>([]);
const [selected, setSelected] = createSignal<Function>();
const [code, setCode] = createSignal("");
const [codeDirty, setCodeDirty] = createSignal(false);
const [runs, setRuns] = createSignal<FunctionRun[]>([]);
const [payload, setPayload] = createSignal("{}");
const [lastRun, setLastRun] = createSignal<FunctionRun>();
const [message, setMessage] = createSignal("");
const [error, setError] = createSignal("");
const [busy, setBusy] = createSignal<string>();
const [showCreate, setShowCreate] = createSignal(false);
const [createForm, setCreateForm] = createSignal({ name: "", runtime: "bun", event_pattern: "" });
const [eventPattern, setEventPattern] = createSignal("");
const [patternDirty, setPatternDirty] = createSignal(false);
const refresh = async () => {
if (!props.projectID) return;
if (props.demoMode) {
setFunctions(DEMO_FUNCTIONS);
return;
}
try {
const res = await FunctionsService.listFunctions({ projectId: props.projectID });
setFunctions(res.items ?? []);
} catch (e) {
setError(err(e));
}
};
const refreshRuns = async (fn: Function) => {
if (!props.projectID || props.demoMode) return;
try {
const res = await FunctionsService.listFunctionRuns({ projectId: props.projectID, functionId: fn.id, limit: 20 });
setRuns(res.items ?? []);
} catch {
/* run history is best-effort */
}
};
const select = async (fn: Function) => {
setSelected(fn);
setCodeDirty(false);
setEventPattern(fn.event_pattern ?? "");
setPatternDirty(false);
setLastRun(undefined);
if (props.demoMode) {
setCode(STARTER_CODE);
return;
}
try {
const res = await FunctionsService.getFunction({ projectId: props.projectID!, functionId: fn.id });
setCode(res.code);
await refreshRuns(fn);
} catch (e) {
setError(err(e));
}
};
createEffect(() => void refresh());
const create = async (e: SubmitEvent) => {
e.preventDefault();
if (!props.projectID || props.demoMode) return;
setBusy("create");
try {
const fn = await FunctionsService.createFunction({
projectId: props.projectID,
requestBody: {
name: createForm().name,
runtime: createForm().runtime as CreateFunctionRequest.runtime,
code: STARTER_CODE,
event_pattern: createForm().event_pattern.trim() || undefined,
},
});
setShowCreate(false);
setCreateForm({ name: "", runtime: "bun", event_pattern: "" });
setMessage(`Function "${fn.name}" created.`);
await refresh();
await select(fn);
} catch (e2) {
setError(err(e2));
} finally {
setBusy(undefined);
}
};
const save = async () => {
const fn = selected();
if (!fn || !props.projectID || props.demoMode) return;
setBusy("save");
try {
await FunctionsService.updateFunction({
projectId: props.projectID,
functionId: fn.id,
requestBody: { code: code(), event_pattern: eventPattern().trim() },
});
setCodeDirty(false);
setPatternDirty(false);
setSelected({ ...fn, event_pattern: eventPattern().trim() });
setFunctions((list) => list.map((f) => (f.id === fn.id ? { ...f, event_pattern: eventPattern().trim() } : f)));
setMessage(`Saved "${fn.name}".`);
} catch (e) {
setError(err(e));
} finally {
setBusy(undefined);
}
};
const invoke = async () => {
const fn = selected();
if (!fn || !props.projectID || props.demoMode) return;
let parsed: unknown;
try {
parsed = JSON.parse(payload() || "{}");
} catch {
setError("Payload is not valid JSON.");
return;
}
setBusy("invoke");
setError("");
try {
const run = await FunctionsService.invokeFunction({
projectId: props.projectID,
functionId: fn.id,
requestBody: { payload: parsed },
});
setLastRun(run);
} catch (e) {
// 502 still returns a FunctionRun body — the run is recorded either way.
if (e instanceof ApiError && e.body && typeof e.body === "object" && "status" in e.body) {
setLastRun(e.body as FunctionRun);
} else {
setError(err(e));
}
} finally {
setBusy(undefined);
await refreshRuns(fn);
}
};
const toggleEnabled = async (fn: Function) => {
if (!props.projectID || props.demoMode) return;
try {
await FunctionsService.updateFunction({ projectId: props.projectID, functionId: fn.id, requestBody: { enabled: !fn.enabled } });
await refresh();
if (selected()?.id === fn.id) setSelected({ ...fn, enabled: !fn.enabled });
} catch (e) {
setError(err(e));
}
};
const remove = async (fn: Function) => {
if (!props.projectID || props.demoMode) return;
setBusy(fn.id);
try {
await FunctionsService.deleteFunction({ projectId: props.projectID, functionId: fn.id });
if (selected()?.id === fn.id) setSelected(undefined);
setMessage(`Deleted "${fn.name}".`);
await refresh();
} catch (e) {
setError(err(e));
} finally {
setBusy(undefined);
}
};
const runBadge = (status: string) =>
status === "success" ? "success" : status === "timeout" ? "warning" : "error";
return (
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">Functions</h1>
<p class="page-description">
Serverless JS/TS executed by a runtime on this instance (<code>bun</code> or <code>deno</code>).
The payload arrives on stdin and as <code>PRIMORA_PAYLOAD</code>; stdout is captured per run.
</p>
</div>
<Show when={props.canManage && !props.demoMode}>
<button class="btn btn-primary" onClick={() => setShowCreate(true)}>
<IconPlus class="w-4 h-4" /> New function
</button>
</Show>
</div>
<Show when={message()}>
<div class="message message-success" onClick={() => setMessage("")}>{message()}</div>
</Show>
<Show when={error()}>
<div class="message message-error" onClick={() => setError("")}>{error()}</div>
</Show>
<Show
when={!props.projectID && !props.demoMode ? false : true}
fallback={<div class="card card-flush empty-state">Select a project to manage functions.</div>}
>
<Show
when={functions().length > 0}
fallback={
<div class="card card-flush empty-state">
<IconTerminal class="w-8 h-8" />
<p>No functions yet.</p>
<p class="text-text-3" style="font-size:0.85rem">
Functions run your code on demand — invoke them from the dashboard, the API, or wire them to schedules and hooks.
</p>
</div>
}
>
<div class="card card-flush">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Runtime</th>
<th>Status</th>
<th>Updated</th>
<th style="width:1%"> </th>
</tr>
</thead>
<tbody>
<For each={functions()}>
{(fn) => (
<tr
style={selected()?.id === fn.id ? "background:var(--bg-3)" : "cursor:pointer"}
onClick={() => void select(fn)}
>
<td class="font-medium text-text-1">{fn.name}</td>
<td><Badge variant="neutral">{fn.runtime}</Badge></td>
<td>
<Badge variant={fn.enabled ? "success" : "neutral"}>{fn.enabled ? "enabled" : "disabled"}</Badge>
</td>
<td class="text-text-3">{fmtTime(fn.updated_at)}</td>
<td>
<Show when={props.canManage && !props.demoMode}>
<div class="flex gap-1">
<button
class="btn btn-ghost btn-sm"
title={fn.enabled ? "Disable" : "Enable"}
onClick={(e) => { e.stopPropagation(); void toggleEnabled(fn); }}
>
{fn.enabled ? "⏸" : "▶"}
</button>
<button
class="btn btn-ghost btn-sm text-danger"
disabled={busy() === fn.id}
onClick={(e) => { e.stopPropagation(); void remove(fn); }}
>
<IconTrash class="w-4 h-4" />
</button>
</div>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
</Show>
<Show when={selected()}>
{(fn) => (
<div class="card card-flush" style="margin-top:1.25rem">
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
<div class="flex items-center justify-between" style="width:100%">
<span class="card-header-title">
{fn().name} <Badge variant="neutral">{fn().runtime}</Badge>
<Show when={fn().event_pattern}>
<Badge variant="warning">on {fn().event_pattern}</Badge>
</Show>
</span>
<div class="flex gap-2 items-center">
<Show when={props.canManage && !props.demoMode}>
<button class="btn btn-secondary btn-sm" disabled={(!codeDirty() && !patternDirty()) || busy() === "save"} onClick={() => void save()}>
{busy() === "save" ? "Saving…" : "Save"}
</button>
<button class="btn btn-primary btn-sm" disabled={busy() === "invoke" || !fn().enabled} onClick={() => void invoke()}>
<IconPlay class="w-3.5 h-3.5" /> {busy() === "invoke" ? "Running…" : "Invoke"}
</button>
</Show>
</div>
</div>
</div>
<div style="padding:1rem 1.25rem 0">
<Input
label="Event trigger (optional)"
placeholder='document.* · * · deploy.marker'
value={eventPattern()}
onInput={(e) => { setEventPattern(e.currentTarget.value); setPatternDirty(true); }}
disabled={!props.canManage || props.demoMode}
/>
<p class="text-text-3" style="font-size:0.78rem;margin-top:0.3rem">
Runs whenever a project event matching this pattern is published — e.g.{" "}
<code>document.*</code>, <code>object.created</code>, or <code>*</code> for all. The
function receives {"{ event, data, occurred_at }"}. Schedules and inbound hooks can
also target functions.
</p>
</div>
<div style="padding:1rem 1.25rem;display:grid;gap:1rem;grid-template-columns:1fr 1fr">
<div>
<label class="text-text-3" style="font-size:0.8rem;display:block;margin-bottom:0.35rem">Code</label>
<Textarea
rows={14}
value={code()}
onInput={(e) => { setCode(e.currentTarget.value); setCodeDirty(true); }}
disabled={!props.canManage || props.demoMode}
style="font-family:var(--font-mono);font-size:0.8rem"
/>
</div>
<div>
<label class="text-text-3" style="font-size:0.8rem;display:block;margin-bottom:0.35rem">Payload (JSON)</label>
<Textarea
rows={5}
value={payload()}
onInput={(e) => setPayload(e.currentTarget.value)}
disabled={!props.canManage || props.demoMode}
style="font-family:var(--font-mono);font-size:0.8rem"
/>
<Show when={lastRun()}>
{(run) => (
<div style="margin-top:0.75rem">
<div class="flex items-center gap-2" style="margin-bottom:0.35rem">
<Badge variant={runBadge(run().status)}>{run().status}</Badge>
<span class="text-text-3" style="font-size:0.8rem">
{run().duration_ms} ms{run().exit_code != null ? ` · exit ${run().exit_code}` : ""}
</span>
</div>
<Show when={run().stdout}>
<pre class="text-text-2" style="background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:0.6rem;font-size:0.78rem;max-height:10rem;overflow:auto;white-space:pre-wrap">{run().stdout}</pre>
</Show>
<Show when={run().stderr}>
<pre class="text-danger" style="background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:0.6rem;font-size:0.78rem;max-height:10rem;overflow:auto;white-space:pre-wrap">{run().stderr}</pre>
</Show>
</div>
)}
</Show>
</div>
</div>
</div>
)}
</Show>
<Show when={selected() && runs().length > 0}>
<div class="card card-flush" style="margin-top:1.25rem">
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
<span class="card-header-title">Recent runs</span>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Status</th>
<th>Trigger</th>
<th>Duration</th>
<th>When</th>
</tr>
</thead>
<tbody>
<For each={runs()}>
{(run) => (
<tr>
<td><Badge variant={runBadge(run.status)}>{run.status}</Badge></td>
<td class="text-text-2">{run.trigger}</td>
<td class="text-text-3">{run.duration_ms} ms</td>
<td class="text-text-3">{fmtTime(run.created_at)}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
<Modal error={error()} open={showCreate()} onClose={() => setShowCreate(false)} title="New function">
<form onSubmit={create}>
<div class="modal-body">
<Input
label="Name"
placeholder="resize-image"
value={createForm().name}
onInput={(e) => setCreateForm((f) => ({ ...f, name: e.currentTarget.value }))}
required
/>
<Select
label="Runtime"
value={createForm().runtime}
onChange={(e) => setCreateForm((f) => ({ ...f, runtime: e.currentTarget.value }))}
>
<option value="bun">bun</option>
<option value="deno">deno</option>
</Select>
<Input
label="Event trigger (optional)"
placeholder="document.*"
value={createForm().event_pattern}
onInput={(e) => setCreateForm((f) => ({ ...f, event_pattern: e.currentTarget.value }))}
/>
<p class="text-text-3" style="font-size:0.8rem">
Starts from a stdin-echo template — edit the code after creating.
</p>
</div>
<ModalFooter>
<button type="button" class="btn btn-secondary" onClick={() => setShowCreate(false)}>Cancel</button>
<button type="submit" class="btn btn-primary" disabled={busy() === "create" || !createForm().name.trim()}>
{busy() === "create" ? "Creating…" : "Create function"}
</button>
</ModalFooter>
</form>
</Modal>
</div>
);
}
+362 -5
View File
@@ -1,8 +1,18 @@
import { For, Show, createEffect, createSignal } from "solid-js"; import { For, Show, createEffect, createSignal } from "solid-js";
import { import {
AlertsService,
AutomationService,
CreateInboundHookRequest,
CreateIntegrationRequest, CreateIntegrationRequest,
FunctionsService,
InboundService,
IntegrationsService, IntegrationsService,
UpsertAlertRuleRequest,
WebhooksService, WebhooksService,
type AlertRule,
type Function as PrimoraFunction,
type InboundHook,
type ScheduledJob,
type Integration, type Integration,
type IntegrationAnalytics, type IntegrationAnalytics,
type Webhook, type Webhook,
@@ -12,6 +22,7 @@ import { demoService } from "../lib/demo-mode";
import { Badge } from "../components/Badge"; import { Badge } from "../components/Badge";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { Input, Select } from "../components/Input"; import { Input, Select } from "../components/Input";
import { errorMessage } from "../lib/api";
import { import {
IconPlus, IconPlus,
IconRefresh, IconRefresh,
@@ -30,7 +41,7 @@ interface IntegrationsPageProps {
type Tab = "integrations" | "webhooks"; type Tab = "integrations" | "webhooks";
const err = (e: unknown) => (e instanceof Error ? e.message : String(e)); const err = (e: unknown) => errorMessage(e, String(e));
const intSvc = (demo: boolean) => const intSvc = (demo: boolean) =>
(demo ? (demoService as unknown as typeof IntegrationsService) : IntegrationsService); (demo ? (demoService as unknown as typeof IntegrationsService) : IntegrationsService);
const hookSvc = (demo: boolean) => const hookSvc = (demo: boolean) =>
@@ -48,7 +59,7 @@ const statusVariant = (s?: string) =>
? "warning" ? "warning"
: "neutral"; : "neutral";
const EVENT_TYPES = ["issue.created", "deploy.marker", "webhook.test"] as const; const EVENT_TYPES = ["issue.created", "deploy.marker", "webhook.test", "alert.fired", "alert.resolved"] as const;
export function IntegrationsPage(props: IntegrationsPageProps) { export function IntegrationsPage(props: IntegrationsPageProps) {
const [tab, setTab] = createSignal<Tab>("integrations"); const [tab, setTab] = createSignal<Tab>("integrations");
@@ -59,8 +70,23 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
const [analyticsSite, setAnalyticsSite] = createSignal<Record<string, string>>({}); const [analyticsSite, setAnalyticsSite] = createSignal<Record<string, string>>({});
const [expandedIntegration, setExpandedIntegration] = createSignal<string>(); const [expandedIntegration, setExpandedIntegration] = createSignal<string>();
const [expandedWebhook, setExpandedWebhook] = createSignal<string>(); const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
const [alertRules, setAlertRules] = createSignal<AlertRule[]>([]);
const [inboundHooks, setInboundHooks] = createSignal<InboundHook[]>([]);
const [scheduledJobs, setScheduledJobs] = createSignal<ScheduledJob[]>([]);
const [showCreateIntegration, setShowCreateIntegration] = createSignal(false); const [showCreateIntegration, setShowCreateIntegration] = createSignal(false);
const [showCreateWebhook, setShowCreateWebhook] = createSignal(false); const [showCreateWebhook, setShowCreateWebhook] = createSignal(false);
const [showCreateAlert, setShowCreateAlert] = createSignal(false);
const [showCreateInbound, setShowCreateInbound] = createSignal(false);
const [inboundForm, setInboundForm] = createSignal({ name: "", mode: CreateInboundHookRequest.mode.EVENT as CreateInboundHookRequest.mode, job_id: "", function_id: "", secret: "" });
const [functions, setFunctions] = createSignal<PrimoraFunction[]>([]);
const [copiedUrl, setCopiedUrl] = createSignal("");
const [alertForm, setAlertForm] = createSignal({
name: "",
kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE as UpsertAlertRuleRequest.kind,
component: "",
threshold: "5",
window: "15",
});
const [integrationForm, setIntegrationForm] = createSignal({ name: "", base_url: "", api_key: "", site_id: "" }); const [integrationForm, setIntegrationForm] = createSignal({ name: "", base_url: "", api_key: "", site_id: "" });
const [webhookForm, setWebhookForm] = createSignal({ url: "", secret: "", events: [] as string[], enabled: true }); const [webhookForm, setWebhookForm] = createSignal({ url: "", secret: "", events: [] as string[], enabled: true });
const [markerForm, setMarkerForm] = createSignal({ version: "", ref: "", environment: "", note: "" }); const [markerForm, setMarkerForm] = createSignal({ version: "", ref: "", environment: "", note: "" });
@@ -75,12 +101,20 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const [int, hooks] = await Promise.all([ const [int, hooks, rules, inbound, jobs, fns] = await Promise.all([
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }), intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }), hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }),
props.demoMode ? Promise.resolve({ items: [] }) : AlertsService.listAlertRules({ projectId: props.projectID }),
props.demoMode ? Promise.resolve({ items: [] }) : InboundService.listInboundHooks({ projectId: props.projectID }),
props.demoMode ? Promise.resolve({ items: [] }) : AutomationService.listScheduledJobs({ projectId: props.projectID }),
props.demoMode ? Promise.resolve({ items: [] }) : FunctionsService.listFunctions({ projectId: props.projectID }),
]); ]);
setIntegrations(int.items ?? []); setIntegrations(int.items ?? []);
setWebhooks(hooks.items ?? []); setWebhooks(hooks.items ?? []);
setAlertRules(rules.items ?? []);
setInboundHooks(inbound.items ?? []);
setScheduledJobs(jobs.items ?? []);
setFunctions(fns.items ?? []);
} catch (e) { } catch (e) {
setError(err(e)); setError(err(e));
} finally { } finally {
@@ -205,6 +239,64 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
"Webhook deleted", "Webhook deleted",
); );
const createAlertRule = (e: Event) => {
e.preventDefault();
const form = alertForm();
const threshold = parseInt(form.threshold, 10) || 0;
const config =
form.kind === UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE
? { component: form.component.trim() || undefined, threshold_minutes: threshold }
: { component: form.component.trim() || undefined, threshold_count: threshold, window_minutes: parseInt(form.window, 10) || 15 };
void run("create-alert", async () => {
await AlertsService.createAlertRule({
projectId: props.projectID!,
requestBody: { name: form.name.trim(), kind: form.kind, config },
});
setShowCreateAlert(false);
setAlertForm({ name: "", kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE, component: "", threshold: "5", window: "15" });
}, "Alert rule created");
};
const deleteAlertRule = (id: string) =>
run(
`del-alert-${id}`,
() => AlertsService.deleteAlertRule({ projectId: props.projectID!, ruleId: id }),
"Alert rule deleted",
);
const createInboundHook = (e: Event) => {
e.preventDefault();
const form = inboundForm();
void run("create-inbound", async () => {
await InboundService.createInboundHook({
projectId: props.projectID!,
requestBody: {
name: form.name.trim(),
mode: form.mode,
job_id: form.mode === CreateInboundHookRequest.mode.JOB ? form.job_id : undefined,
function_id: form.mode === CreateInboundHookRequest.mode.FUNCTION ? form.function_id : undefined,
secret: form.secret.trim() || undefined,
},
});
setShowCreateInbound(false);
setInboundForm({ name: "", mode: CreateInboundHookRequest.mode.EVENT, job_id: "", function_id: "", secret: "" });
}, "Inbound hook created");
};
const deleteInboundHook = (id: string) =>
run(
`del-inbound-${id}`,
() => InboundService.deleteInboundHook({ projectId: props.projectID!, hookId: id }),
"Inbound hook deleted",
);
const copyHookUrl = (hook: InboundHook) => {
void navigator.clipboard?.writeText(hook.url).then(() => {
setCopiedUrl(hook.id);
setTimeout(() => setCopiedUrl(""), 1500);
});
};
const testWebhook = (id: string) => const testWebhook = (id: string) =>
run( run(
`test-hook-${id}`, `test-hook-${id}`,
@@ -506,6 +598,158 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
</Show> </Show>
</div> </div>
<div class="card card-flush mt-4">
<div class="card-header">
<div class="flex-1">
<div class="card-header-title">Alert rules</div>
<div class="card-header-description">
Telemetry conditions that fire <code>alert.fired</code> / <code>alert.resolved</code> events —
subscribe a webhook above to receive them.
</div>
</div>
<Show when={props.canManage}>
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateAlert(true)}>
<IconPlus class="w-4 h-4" />
Add rule
</button>
</Show>
</div>
<Show
when={alertRules().length > 0}
fallback={
<p class="text-text-2 text-sm p-4">
No alert rules yet. Watch for heartbeat silence or error spikes across your components.
</p>
}
>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Condition</th>
<th>Scope</th>
<th>State</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<For each={alertRules()}>
{(rule) => (
<tr>
<td class="font-medium">{rule.name}</td>
<td class="text-text-2">
{rule.kind === "heartbeat_silence"
? `no heartbeat for ${rule.config.threshold_minutes ?? "?"}m`
: `≥${rule.config.threshold_count ?? "?"} errors in ${rule.config.window_minutes ?? 15}m`}
</td>
<td>
<Show when={rule.config.component} fallback={<span class="text-text-2">all components</span>}>
<Badge variant="neutral">{rule.config.component}</Badge>
</Show>
</td>
<td>
<Show
when={rule.firing.length > 0}
fallback={<Badge variant={rule.enabled ? "success" : "neutral"}>{rule.enabled ? "watching" : "disabled"}</Badge>}
>
<Badge variant="error">firing: {rule.firing.join(", ")}</Badge>
</Show>
</td>
<td style="text-align:right;white-space:nowrap">
<Show when={props.canManage}>
<button
class="btn btn-ghost btn-sm"
onClick={() => void deleteAlertRule(rule.id)}
disabled={busy() === `del-alert-${rule.id}`}
>
<IconTrash class="w-4 h-4" />
</button>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
<div class="card card-flush mt-4">
<div class="card-header">
<div class="flex-1">
<div class="card-header-title">Inbound hooks</div>
<div class="card-header-description">
Public ingest endpoints — POST from external systems to republish as <code>inbound.received</code>
events or trigger a scheduled job.
</div>
</div>
<Show when={props.canManage}>
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateInbound(true)}>
<IconPlus class="w-4 h-4" />
Add hook
</button>
</Show>
</div>
<Show
when={inboundHooks().length > 0}
fallback={<p class="text-text-2 text-sm p-4">No inbound hooks yet.</p>}
>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Mode</th>
<th>Security</th>
<th>Last received</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<For each={inboundHooks()}>
{(hook) => (
<tr>
<td class="font-medium">{hook.name}</td>
<td>
<Badge variant="neutral">
{hook.mode === "job" ? "job trigger" : hook.mode === "function" ? `ƒ ${functions().find((f) => f.id === hook.function_id)?.name ?? "function"}` : "event"}
</Badge>
<Show when={!hook.enabled}>
{" "}<Badge variant="warning">disabled</Badge>
</Show>
</td>
<td>
<Show when={hook.has_secret} fallback={<span class="text-text-2">token only</span>}>
<Badge variant="success">signed</Badge>
</Show>
</td>
<td class="text-text-2">{fmtTime(hook.last_received_at)}</td>
<td style="text-align:right;white-space:nowrap">
<button class="btn btn-ghost btn-sm" onClick={() => copyHookUrl(hook)} title={hook.url}>
<IconCopy class="w-4 h-4" />
{copiedUrl() === hook.id ? "Copied" : "URL"}
</button>
<Show when={props.canManage}>
<button
class="btn btn-ghost btn-sm"
onClick={() => void deleteInboundHook(hook.id)}
disabled={busy() === `del-inbound-${hook.id}`}
>
<IconTrash class="w-4 h-4" />
</button>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
<Show when={props.canManage}> <Show when={props.canManage}>
<div class="card mt-4"> <div class="card mt-4">
<div class="card-header"> <div class="card-header">
@@ -555,7 +799,7 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
</Show> </Show>
</Show> </Show>
<Modal open={showCreateIntegration()} onClose={() => setShowCreateIntegration(false)} title="Add connector" size="md"> <Modal error={error()} open={showCreateIntegration()} onClose={() => setShowCreateIntegration(false)} title="Add connector" size="md">
<form onSubmit={createIntegration} class="space-y-4"> <form onSubmit={createIntegration} class="space-y-4">
<Select label="Type" value="rybbit" disabled options={[{ value: "rybbit", label: "Rybbit — web analytics" }]} /> <Select label="Type" value="rybbit" disabled options={[{ value: "rybbit", label: "Rybbit — web analytics" }]} />
<Input <Input
@@ -596,7 +840,7 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
</form> </form>
</Modal> </Modal>
<Modal open={showCreateWebhook()} onClose={() => setShowCreateWebhook(false)} title="Add webhook" size="md"> <Modal error={error()} open={showCreateWebhook()} onClose={() => setShowCreateWebhook(false)} title="Add webhook" size="md">
<form onSubmit={createWebhook} class="space-y-4"> <form onSubmit={createWebhook} class="space-y-4">
<Input <Input
label="Endpoint URL" label="Endpoint URL"
@@ -639,6 +883,119 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
</div> </div>
</form> </form>
</Modal> </Modal>
<Modal error={error()} open={showCreateAlert()} onClose={() => setShowCreateAlert(false)} title="Add alert rule" size="md">
<form onSubmit={createAlertRule} class="space-y-4">
<Input
label="Name"
placeholder="api_down"
required
value={alertForm().name}
onInput={(e) => setAlertForm((c) => ({ ...c, name: e.currentTarget.value }))}
/>
<Select
label="Condition"
value={alertForm().kind}
onChange={(e) => setAlertForm((c) => ({ ...c, kind: e.currentTarget.value as UpsertAlertRuleRequest.kind }))}
options={[
{ value: "heartbeat_silence", label: "Heartbeat silence — component stops reporting" },
{ value: "error_spike", label: "Error spike — too many errors in a window" },
]}
/>
<Input
label="Component"
placeholder="empty = all components"
value={alertForm().component}
onInput={(e) => setAlertForm((c) => ({ ...c, component: e.currentTarget.value }))}
/>
<div class="grid gap-3 sm:grid-cols-2">
<Input
label={alertForm().kind === UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE ? "Silent for (minutes)" : "Errors at least"}
type="number"
required
value={alertForm().threshold}
onInput={(e) => setAlertForm((c) => ({ ...c, threshold: e.currentTarget.value }))}
/>
<Show when={alertForm().kind === UpsertAlertRuleRequest.kind.ERROR_SPIKE}>
<Input
label="Window (minutes)"
type="number"
value={alertForm().window}
onInput={(e) => setAlertForm((c) => ({ ...c, window: e.currentTarget.value }))}
/>
</Show>
</div>
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateAlert(false)}>
Cancel
</button>
<button type="submit" class="btn btn-primary" disabled={busy() === "create-alert"}>
Create
</button>
</div>
</form>
</Modal>
<Modal error={error()} open={showCreateInbound()} onClose={() => setShowCreateInbound(false)} title="Add inbound hook" size="md">
<form onSubmit={createInboundHook} class="space-y-4">
<Input
label="Name"
placeholder="github_ci"
required
value={inboundForm().name}
onInput={(e) => setInboundForm((c) => ({ ...c, name: e.currentTarget.value }))}
/>
<Select
label="On receive"
value={inboundForm().mode}
onChange={(e) => setInboundForm((c) => ({ ...c, mode: e.currentTarget.value as CreateInboundHookRequest.mode }))}
options={[
{ value: "event", label: "Publish inbound.received event" },
{ value: "job", label: "Trigger a scheduled job" },
{ value: "function", label: "Invoke a function" },
]}
/>
<Show when={inboundForm().mode === CreateInboundHookRequest.mode.FUNCTION}>
<Select
label="Function"
value={inboundForm().function_id}
onChange={(e) => setInboundForm((c) => ({ ...c, function_id: e.currentTarget.value }))}
options={[
{ value: "", label: "Select a function…" },
...functions().map((f) => ({ value: f.id, label: f.name })),
]}
/>
<p class="text-text-2 text-xs">The received body becomes the function's stdin payload.</p>
</Show>
<Show when={inboundForm().mode === CreateInboundHookRequest.mode.JOB}>
<Select
label="Job"
value={inboundForm().job_id}
onChange={(e) => setInboundForm((c) => ({ ...c, job_id: e.currentTarget.value }))}
options={[
{ value: "", label: "Select a job…" },
...scheduledJobs().map((j) => ({ value: j.id, label: j.name })),
]}
/>
<p class="text-text-2 text-xs">The received body becomes the job's payload for that run.</p>
</Show>
<Input
label="Secret"
type="password"
placeholder="Optional — callers must sign X-Primora-Signature"
value={inboundForm().secret}
onInput={(e) => setInboundForm((c) => ({ ...c, secret: e.currentTarget.value }))}
/>
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateInbound(false)}>
Cancel
</button>
<button type="submit" class="btn btn-primary" disabled={busy() === "create-inbound"}>
Create
</button>
</div>
</form>
</Modal>
</div> </div>
); );
} }
+61 -5
View File
@@ -1,8 +1,10 @@
import { For, Show, createMemo, createSignal } from "solid-js"; import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
import type { import {
OrganizationInvitation, EmailService,
OrganizationMember, type EmailLogEntry,
ProjectMember, type OrganizationInvitation,
type OrganizationMember,
type ProjectMember,
} from "@primora/api-client"; } from "@primora/api-client";
import { Badge } from "../components/Badge"; import { Badge } from "../components/Badge";
import { Modal, ModalFooter } from "../components/Modal"; import { Modal, ModalFooter } from "../components/Modal";
@@ -22,6 +24,8 @@ interface InvitationInput {
} }
interface MembersPageProps { interface MembersPageProps {
projectID?: string;
demoMode?: boolean;
organizationMembers?: OrganizationMember[]; organizationMembers?: OrganizationMember[];
organizationInvitations?: OrganizationInvitation[]; organizationInvitations?: OrganizationInvitation[];
projectMembers?: ProjectMember[]; projectMembers?: ProjectMember[];
@@ -80,6 +84,15 @@ export function MembersPage(props: MembersPageProps) {
const [tab, setTab] = createSignal<"org" | "project">("org"); const [tab, setTab] = createSignal<"org" | "project">("org");
const [inviteOpen, setInviteOpen] = createSignal(false); const [inviteOpen, setInviteOpen] = createSignal(false);
const [query, setQuery] = createSignal(""); const [query, setQuery] = createSignal("");
const [emails, setEmails] = createSignal<EmailLogEntry[]>([]);
createEffect(() => {
const pid = props.projectID;
if (!pid || props.demoMode) return;
void EmailService.listEmailLog({ projectId: pid, limit: 20 })
.then((res) => setEmails(res.items ?? []))
.catch(() => {});
});
const filteredOrgMembers = createMemo(() => { const filteredOrgMembers = createMemo(() => {
const q = query().toLowerCase(); const q = query().toLowerCase();
@@ -368,6 +381,49 @@ export function MembersPage(props: MembersPageProps) {
</div> </div>
</Show> </Show>
<Show when={emails().length > 0}>
<div class="card card-flush">
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
<span class="card-header-title">Email log</span>
<span class="card-header-description">
Transactional email sent for this project — every send is recorded, including failures.
</span>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>To</th>
<th>Subject</th>
<th>Template</th>
<th>Status</th>
<th>Sent</th>
</tr>
</thead>
<tbody>
<For each={emails()}>
{(mail) => (
<tr>
<td class="font-medium text-text-1">{mail.to_email}</td>
<td class="text-text-2" style="max-width:18rem;overflow:hidden;text-overflow:ellipsis" title={mail.error || mail.subject}>
{mail.subject}
</td>
<td>
<Badge variant="neutral">{mail.template}</Badge>
</td>
<td>
<Badge variant={mail.status === "sent" ? "success" : "error"}>{mail.status}</Badge>
</td>
<td class="text-text-3">{formatDate(mail.created_at)}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
</Show>
{/* Invite modal */} {/* Invite modal */}
<Modal <Modal
open={inviteOpen()} open={inviteOpen()}
+2 -1
View File
@@ -12,6 +12,7 @@ import { demoService } from "../lib/demo-mode";
import { Badge } from "../components/Badge"; import { Badge } from "../components/Badge";
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { Select } from "../components/Input"; import { Select } from "../components/Input";
import { errorMessage } from "../lib/api";
import { import {
IconChevronRight, IconChevronRight,
IconRefresh, IconRefresh,
@@ -25,7 +26,7 @@ interface TelemetryPageProps {
type Tab = "health" | "issues" | "events" | "metrics" | "integrate"; type Tab = "health" | "issues" | "events" | "metrics" | "integrate";
const err = (e: unknown) => (e instanceof Error ? e.message : String(e)); const err = (e: unknown) => errorMessage(e, String(e));
const svc = (demo: boolean) => const svc = (demo: boolean) =>
(demo ? (demoService as unknown as typeof TelemetryService) : TelemetryService); (demo ? (demoService as unknown as typeof TelemetryService) : TelemetryService);
+4 -3
View File
@@ -6,6 +6,7 @@ import {
import { Modal } from "../components/Modal"; import { Modal } from "../components/Modal";
import { Input, Select, Textarea } from "../components/Input"; import { Input, Select, Textarea } from "../components/Input";
import { IconPlus, IconTrash, IconCopy, IconRefresh, IconKey } from "../components/Icons"; import { IconPlus, IconTrash, IconCopy, IconRefresh, IconKey } from "../components/Icons";
import { errorMessage } from "../lib/api";
interface VaultPageProps { interface VaultPageProps {
projectID?: string; projectID?: string;
@@ -13,7 +14,7 @@ interface VaultPageProps {
demoMode: boolean; demoMode: boolean;
} }
const err = (e: unknown) => (e instanceof Error ? e.message : String(e)); const err = (e: unknown) => errorMessage(e, String(e));
const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—"); const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—");
const DEMO_SECRETS: ProjectSecret[] = [ const DEMO_SECRETS: ProjectSecret[] = [
@@ -376,7 +377,7 @@ export function VaultPage(props: VaultPageProps) {
</div> </div>
</Show> </Show>
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New secret" size="md"> <Modal error={error()} open={showCreate()} onClose={() => setShowCreate(false)} title="New secret" size="md">
<form onSubmit={createSecret} class="space-y-4"> <form onSubmit={createSecret} class="space-y-4">
<Input label="Name" placeholder="STRIPE_SECRET" value={form().name} onInput={(e) => setForm({ ...form(), name: e.currentTarget.value })} required /> <Input label="Name" placeholder="STRIPE_SECRET" value={form().name} onInput={(e) => setForm({ ...form(), name: e.currentTarget.value })} required />
<Input label="Value" type="password" placeholder="sk_live_…" value={form().value} onInput={(e) => setForm({ ...form(), value: e.currentTarget.value })} required /> <Input label="Value" type="password" placeholder="sk_live_…" value={form().value} onInput={(e) => setForm({ ...form(), value: e.currentTarget.value })} required />
@@ -389,7 +390,7 @@ export function VaultPage(props: VaultPageProps) {
</form> </form>
</Modal> </Modal>
<Modal open={showImport()} onClose={() => setShowImport(false)} title="Import .env" size="md"> <Modal error={error()} open={showImport()} onClose={() => setShowImport(false)} title="Import .env" size="md">
<form onSubmit={importEnv} class="space-y-4"> <form onSubmit={importEnv} class="space-y-4">
<Textarea label="Paste .env contents" rows={8} placeholder={"KEY=value\nOTHER=…"} value={importText()} onInput={(e) => setImportText(e.currentTarget.value)} class="font-mono text-sm" /> <Textarea label="Paste .env contents" rows={8} placeholder={"KEY=value\nOTHER=…"} value={importText()} onInput={(e) => setImportText(e.currentTarget.value)} class="font-mono text-sm" />
<p class="text-xs text-text-2">Existing names are overwritten. Keys that aren't env-var-safe are skipped.</p> <p class="text-xs text-text-2">Existing names are overwritten. Keys that aren't env-var-safe are skipped.</p>
+1
View File
@@ -8,6 +8,7 @@ export { DatabasesPage } from "./DatabasesPage";
export { TelemetryPage } from "./TelemetryPage"; export { TelemetryPage } from "./TelemetryPage";
export { IntegrationsPage } from "./IntegrationsPage"; export { IntegrationsPage } from "./IntegrationsPage";
export { VaultPage } from "./VaultPage"; export { VaultPage } from "./VaultPage";
export { FunctionsPage } from "./FunctionsPage";
export { AutomationPage } from "./AutomationPage"; export { AutomationPage } from "./AutomationPage";
export { SettingsPage } from "./SettingsPage"; export { SettingsPage } from "./SettingsPage";
export { AuditPage } from "./AuditPage"; export { AuditPage } from "./AuditPage";
-9
View File
@@ -38,15 +38,6 @@ export default defineConfig({
urlPattern: /\/(api|auth|mailpit)(\/|$)/, urlPattern: /\/(api|auth|mailpit)(\/|$)/,
handler: "NetworkOnly", handler: "NetworkOnly",
}, },
{
// Geist font files + CSS from Google Fonts — long-lived static assets.
urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\//,
handler: "CacheFirst",
options: {
cacheName: "primora-fonts",
expiration: { maxEntries: 20, maxAgeSeconds: 60 * 60 * 24 * 365 },
},
},
], ],
}, },
}), }),
+6 -2
View File
@@ -27,8 +27,12 @@ Secrets are agent-safe by design — values are never returned by any tool:
- `primora_vault_status` / `primora_vault_list` — local-vault state and - `primora_vault_status` / `primora_vault_list` — local-vault state and
metadata (names/urls/notes only); requires `primora vault unlock` on the host. metadata (names/urls/notes only); requires `primora vault unlock` on the host.
- `primora_vault_exec` — runs `primora` commands with a vault secret injected - `primora_vault_exec` — runs `primora` commands with a vault secret injected
into the child's env; `vault`/`secrets`/`inject`/`agent`/`login`/`use` and into the child's env. A strict allow-list (read-mostly commands like
`keys:create` are denied so no response can carry a credential. `whoami`, `context`, `orgs:list`, `buckets:list`, `jobs:list`,
`audit:list`, `events:send`) — nothing that reads, writes, or moves a
credential can run; file/path flags are rejected and the injected env
name must be a safe identifier (loader hooks like `NODE_OPTIONS` are
blocked).
- `primora_secrets_list` / `primora_secrets_set` — project-vault metadata and - `primora_secrets_list` / `primora_secrets_set` — project-vault metadata and
write-only storage. No reveal or delete: agents consume secrets through write-only storage. No reveal or delete: agents consume secrets through
`secret://NAME` references in job payloads, resolved server-side at delivery. `secret://NAME` references in job payloads, resolved server-side at delivery.
+2
View File
@@ -49,6 +49,8 @@ async function mintSessionToken(cfg: McpConfig): Promise<string> {
export function configureClient(cfg: McpConfig): void { export function configureClient(cfg: McpConfig): void {
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`; OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
// encodeURI leaves ?#& intact in path params — smuggles query segments.
OpenAPI.ENCODE_PATH = encodeURIComponent;
if (cfg.auth?.type === "apiKey") { if (cfg.auth?.type === "apiKey") {
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key }; OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
return; return;
+164 -56
View File
@@ -22,7 +22,16 @@ import {
import { configureClient, loadConfig, type McpConfig } from "./config.js"; import { configureClient, loadConfig, type McpConfig } from "./config.js";
const cfg = loadConfig(); const cfg = loadConfig();
configureClient(cfg); try {
configureClient(cfg);
} catch (e) {
// Missing credentials must not kill the server — initialize/tools/list
// still work, and every call surfaces the actionable error through the
// normal tool error path instead of an uncaught startup exception.
OpenAPI.TOKEN = async () => {
throw e;
};
}
const TEXT_TYPES = /^(text\/|application\/(json|xml|yaml|javascript|x-ndjson))/; const TEXT_TYPES = /^(text\/|application\/(json|xml|yaml|javascript|x-ndjson))/;
@@ -41,14 +50,62 @@ function fail(error: unknown) {
return { content: [{ type: "text" as const, text: `error: ${message}` }], isError: true }; return { content: [{ type: "text" as const, text: `error: ${message}` }], isError: true };
} }
function projectId(override?: string): string { async function projectId(override?: string): Promise<string> {
const id = override ?? cfg.projectId; // API keys are hard-scoped to one project — a stored `primora use`
// selection for a different project can never authorize, so /context
// wins. Session actors keep the stored choice (users switch projects).
const id =
override ??
(cfg.auth?.type === "apiKey"
? ((await contextProjectId()) ?? cfg.projectId)
: (cfg.projectId ?? (await contextProjectId())));
if (!id) { if (!id) {
throw new Error("no project selected — pass projectId, run `primora use`, or set PRIMORA_PROJECT"); throw new Error("no project selected — pass projectId, run `primora use`, or set PRIMORA_PROJECT");
} }
return id; return id;
} }
async function orgId(override?: string): Promise<string> {
const id =
override ??
(cfg.auth?.type === "apiKey"
? ((await contextOrgId()) ?? cfg.organizationId)
: (cfg.organizationId ?? (await contextOrgId())));
if (!id) {
throw new Error("no organization selected — pass organizationId or run `primora use`");
}
return id;
}
// API keys are project-scoped — /context resolves the project without /me.
async function actorContext(): Promise<ActorContext | undefined> {
if (ctxCache) return ctxCache;
try {
const res = await apiFetch("/context");
ctxCache = (await res.json()) as ActorContext;
} catch {
return undefined;
}
return ctxCache;
}
async function contextProjectId(): Promise<string | undefined> {
return (await actorContext())?.project?.id;
}
async function contextOrgId(): Promise<string | undefined> {
return (await actorContext())?.organization?.id;
}
interface ActorContext {
actor: string;
scopes?: string[];
key_prefix?: string;
organization?: { id: string; slug: string; name: string; role?: string };
project?: { id: string; slug: string; name: string };
}
let ctxCache: ActorContext | undefined;
async function bucketIdFor(project: string, ref: string): Promise<string> { async function bucketIdFor(project: string, ref: string): Promise<string> {
const { items } = await StorageService.listBuckets({ projectId: project }); const { items } = await StorageService.listBuckets({ projectId: project });
const match = items.find((b) => b.id === ref || b.slug === ref || b.name === ref); const match = items.find((b) => b.id === ref || b.slug === ref || b.name === ref);
@@ -83,6 +140,11 @@ server.registerTool(
{ description: "Current identity, organizations and projects visible to the credentials" }, { description: "Current identity, organizations and projects visible to the credentials" },
async () => { async () => {
try { try {
// API keys can't call /me — /context is the actor-aware self-discovery.
if (cfg.auth?.type === "apiKey") {
const res = await apiFetch("/context");
return ok(await res.json());
}
return ok(await PlatformService.getMe()); return ok(await PlatformService.getMe());
} catch (e) { } catch (e) {
return fail(e); return fail(e);
@@ -110,9 +172,7 @@ server.registerTool(
}, },
async ({ organizationId }) => { async ({ organizationId }) => {
try { try {
const org = organizationId ?? cfg.organizationId; return ok(await ProjectsService.listProjects({ organizationId: await orgId(organizationId) }));
if (!org) throw new Error("no organization selected — pass organizationId or run `primora use`");
return ok(await ProjectsService.listProjects({ organizationId: org }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -132,11 +192,9 @@ server.registerTool(
}, },
async ({ name, slug, description, organizationId }) => { async ({ name, slug, description, organizationId }) => {
try { try {
const org = organizationId ?? cfg.organizationId;
if (!org) throw new Error("no organization selected — pass organizationId or run `primora use`");
return ok( return ok(
await ProjectsService.createProject({ await ProjectsService.createProject({
organizationId: org, organizationId: await orgId(organizationId),
requestBody: { name, slug: slug ?? slugify(name), description }, requestBody: { name, slug: slug ?? slugify(name), description },
}), }),
); );
@@ -154,7 +212,7 @@ server.registerTool(
}, },
async ({ projectId: p }) => { async ({ projectId: p }) => {
try { try {
return ok(await StorageService.listBuckets({ projectId: projectId(p) })); return ok(await StorageService.listBuckets({ projectId: await projectId(p) }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -176,7 +234,7 @@ server.registerTool(
try { try {
return ok( return ok(
await StorageService.createBucket({ await StorageService.createBucket({
projectId: projectId(p), projectId: await projectId(p),
requestBody: { requestBody: {
name, name,
slug: slug ?? slugify(name), slug: slug ?? slugify(name),
@@ -208,7 +266,7 @@ server.registerTool(
}, },
async ({ bucket, projectId: p, q, limit, offset }) => { async ({ bucket, projectId: p, q, limit, offset }) => {
try { try {
const bucketId = await bucketIdFor(projectId(p), bucket); const bucketId = await bucketIdFor(await projectId(p), bucket);
return ok(await StorageService.listBucketObjects({ bucketId, q, limit, offset })); return ok(await StorageService.listBucketObjects({ bucketId, q, limit, offset }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
@@ -231,10 +289,16 @@ server.registerTool(
}, },
async ({ bucket, key, content, contentBase64, contentType, projectId: p }) => { async ({ bucket, key, content, contentBase64, contentType, projectId: p }) => {
try { try {
const bucketId = await bucketIdFor(projectId(p), bucket); const bucketId = await bucketIdFor(await projectId(p), bucket);
let blob: Blob; let blob: Blob;
if (contentBase64 !== undefined) { if (contentBase64 !== undefined) {
blob = new Blob([Buffer.from(contentBase64, "base64")], { // Buffer.from(..., "base64") silently drops invalid chars — strict-
// validate so corrupt input can't silently store garbage bytes.
const clean = contentBase64.replace(/\s/g, "");
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(clean) || clean.length % 4 !== 0) {
throw new Error("contentBase64 is not valid base64");
}
blob = new Blob([Buffer.from(clean, "base64")], {
type: contentType ?? "application/octet-stream", type: contentType ?? "application/octet-stream",
}); });
} else if (content !== undefined) { } else if (content !== undefined) {
@@ -266,7 +330,7 @@ server.registerTool(
}, },
async ({ bucket, key, projectId: p }) => { async ({ bucket, key, projectId: p }) => {
try { try {
const bucketId = await bucketIdFor(projectId(p), bucket); const bucketId = await bucketIdFor(await projectId(p), bucket);
const res = await apiFetch(`/buckets/${bucketId}/objects/${encodeURIComponent(key)}`); const res = await apiFetch(`/buckets/${bucketId}/objects/${encodeURIComponent(key)}`);
const type = res.headers.get("content-type") ?? "application/octet-stream"; const type = res.headers.get("content-type") ?? "application/octet-stream";
if (TEXT_TYPES.test(type)) { if (TEXT_TYPES.test(type)) {
@@ -292,7 +356,7 @@ server.registerTool(
}, },
async ({ bucket, key, projectId: p }) => { async ({ bucket, key, projectId: p }) => {
try { try {
const bucketId = await bucketIdFor(projectId(p), bucket); const bucketId = await bucketIdFor(await projectId(p), bucket);
await StorageService.deleteBucketObject({ bucketId, objectKey: key }); await StorageService.deleteBucketObject({ bucketId, objectKey: key });
return ok({ deleted: key }); return ok({ deleted: key });
} catch (e) { } catch (e) {
@@ -309,7 +373,7 @@ server.registerTool(
}, },
async ({ projectId: p }) => { async ({ projectId: p }) => {
try { try {
return ok(await ProjectsService.listApiKeys({ projectId: projectId(p) })); return ok(await ProjectsService.listApiKeys({ projectId: await projectId(p) }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -324,7 +388,7 @@ server.registerTool(
}, },
async ({ name, projectId: p }) => { async ({ name, projectId: p }) => {
try { try {
return ok(await ProjectsService.createApiKey({ projectId: projectId(p), requestBody: { name } })); return ok(await ProjectsService.createApiKey({ projectId: await projectId(p), requestBody: { name } }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -339,7 +403,7 @@ server.registerTool(
}, },
async ({ apiKeyId, projectId: p }) => { async ({ apiKeyId, projectId: p }) => {
try { try {
await ProjectsService.revokeApiKey({ projectId: projectId(p), apiKeyId }); await ProjectsService.revokeApiKey({ projectId: await projectId(p), apiKeyId });
return ok({ revoked: apiKeyId }); return ok({ revoked: apiKeyId });
} catch (e) { } catch (e) {
return fail(e); return fail(e);
@@ -362,7 +426,7 @@ server.registerTool(
async ({ projectId: p, q, action, limit, offset }) => { async ({ projectId: p, q, action, limit, offset }) => {
try { try {
return ok( return ok(
await ProjectsService.listAuditLogs({ projectId: projectId(p), q, action, limit, offset }), await ProjectsService.listAuditLogs({ projectId: await projectId(p), q, action, limit, offset }),
); );
} catch (e) { } catch (e) {
return fail(e); return fail(e);
@@ -383,7 +447,7 @@ server.registerTool(
}, },
async ({ projectId: p, days }) => { async ({ projectId: p, days }) => {
try { try {
return ok(await TelemetryService.listTelemetryIssues({ projectId: projectId(p), days })); return ok(await TelemetryService.listTelemetryIssues({ projectId: await projectId(p), days }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -407,7 +471,7 @@ server.registerTool(
try { try {
return ok( return ok(
await TelemetryService.listTelemetryEvents({ await TelemetryService.listTelemetryEvents({
projectId: projectId(p), projectId: await projectId(p),
type, type,
component, component,
fingerprint, fingerprint,
@@ -432,7 +496,7 @@ server.registerTool(
}, },
async ({ projectId: p, window }) => { async ({ projectId: p, window }) => {
try { try {
return ok(await TelemetryService.getTelemetryStats({ projectId: projectId(p), window })); return ok(await TelemetryService.getTelemetryStats({ projectId: await projectId(p), window }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -452,7 +516,7 @@ server.registerTool(
async ({ projectId: p, name, window }) => { async ({ projectId: p, name, window }) => {
try { try {
return ok( return ok(
await TelemetryService.getTelemetryMetricSeries({ projectId: projectId(p), name, window }), await TelemetryService.getTelemetryMetricSeries({ projectId: await projectId(p), name, window }),
); );
} catch (e) { } catch (e) {
return fail(e); return fail(e);
@@ -474,7 +538,7 @@ server.registerTool(
}, },
async ({ projectId: p }) => { async ({ projectId: p }) => {
try { try {
return ok(await IntegrationsService.listIntegrations({ projectId: projectId(p) })); return ok(await IntegrationsService.listIntegrations({ projectId: await projectId(p) }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -498,7 +562,7 @@ server.registerTool(
try { try {
return ok( return ok(
await IntegrationsService.createIntegration({ await IntegrationsService.createIntegration({
projectId: projectId(p), projectId: await projectId(p),
requestBody: { requestBody: {
name, name,
type: CreateIntegrationRequest.type.RYBBIT, type: CreateIntegrationRequest.type.RYBBIT,
@@ -522,7 +586,7 @@ server.registerTool(
}, },
async ({ integrationId, projectId: p }) => { async ({ integrationId, projectId: p }) => {
try { try {
await IntegrationsService.deleteIntegration({ projectId: projectId(p), integrationId }); await IntegrationsService.deleteIntegration({ projectId: await projectId(p), integrationId });
return ok({ deleted: integrationId }); return ok({ deleted: integrationId });
} catch (e) { } catch (e) {
return fail(e); return fail(e);
@@ -538,7 +602,7 @@ server.registerTool(
}, },
async ({ integrationId, projectId: p }) => { async ({ integrationId, projectId: p }) => {
try { try {
return ok(await IntegrationsService.testIntegration({ projectId: projectId(p), integrationId })); return ok(await IntegrationsService.testIntegration({ projectId: await projectId(p), integrationId }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -561,7 +625,7 @@ server.registerTool(
try { try {
return ok( return ok(
await IntegrationsService.getIntegrationAnalytics({ await IntegrationsService.getIntegrationAnalytics({
projectId: projectId(p), projectId: await projectId(p),
integrationId, integrationId,
site, site,
days, days,
@@ -603,7 +667,7 @@ server.registerTool(
}, },
async ({ projectId: p }) => { async ({ projectId: p }) => {
try { try {
return ok(await WebhooksService.listWebhooks({ projectId: projectId(p) })); return ok(await WebhooksService.listWebhooks({ projectId: await projectId(p) }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -627,7 +691,7 @@ server.registerTool(
try { try {
return ok( return ok(
await WebhooksService.createWebhook({ await WebhooksService.createWebhook({
projectId: projectId(p), projectId: await projectId(p),
requestBody: { url, secret, events, enabled }, requestBody: { url, secret, events, enabled },
}), }),
); );
@@ -655,7 +719,7 @@ server.registerTool(
try { try {
return ok( return ok(
await WebhooksService.updateWebhook({ await WebhooksService.updateWebhook({
projectId: projectId(p), projectId: await projectId(p),
webhookId, webhookId,
requestBody: { url, secret, events, enabled }, requestBody: { url, secret, events, enabled },
}), }),
@@ -674,7 +738,7 @@ server.registerTool(
}, },
async ({ webhookId, projectId: p }) => { async ({ webhookId, projectId: p }) => {
try { try {
await WebhooksService.deleteWebhook({ projectId: projectId(p), webhookId }); await WebhooksService.deleteWebhook({ projectId: await projectId(p), webhookId });
return ok({ deleted: webhookId }); return ok({ deleted: webhookId });
} catch (e) { } catch (e) {
return fail(e); return fail(e);
@@ -696,7 +760,7 @@ server.registerTool(
try { try {
return ok( return ok(
await WebhooksService.listWebhookDeliveries({ await WebhooksService.listWebhookDeliveries({
projectId: projectId(p), projectId: await projectId(p),
webhookId, webhookId,
limit, limit,
}), }),
@@ -715,7 +779,7 @@ server.registerTool(
}, },
async ({ webhookId, projectId: p }) => { async ({ webhookId, projectId: p }) => {
try { try {
return ok(await WebhooksService.testWebhook({ projectId: projectId(p), webhookId })); return ok(await WebhooksService.testWebhook({ projectId: await projectId(p), webhookId }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -740,7 +804,7 @@ server.registerTool(
if (!version && !ref) throw new Error("pass version or ref"); if (!version && !ref) throw new Error("pass version or ref");
return ok( return ok(
await WebhooksService.createDeployMarker({ await WebhooksService.createDeployMarker({
projectId: projectId(p), projectId: await projectId(p),
requestBody: { version, ref, environment, note }, requestBody: { version, ref, environment, note },
}), }),
); );
@@ -764,7 +828,7 @@ server.registerTool(
}, },
async ({ projectId: p }) => { async ({ projectId: p }) => {
try { try {
return ok(await AutomationService.listScheduledJobs({ projectId: projectId(p) })); return ok(await AutomationService.listScheduledJobs({ projectId: await projectId(p) }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -792,7 +856,7 @@ server.registerTool(
try { try {
return ok( return ok(
await AutomationService.createScheduledJob({ await AutomationService.createScheduledJob({
projectId: projectId(p), projectId: await projectId(p),
requestBody: { name, schedule, url, secret, payload, enabled }, requestBody: { name, schedule, url, secret, payload, enabled },
}), }),
); );
@@ -821,7 +885,7 @@ server.registerTool(
try { try {
return ok( return ok(
await AutomationService.updateScheduledJob({ await AutomationService.updateScheduledJob({
projectId: projectId(p), projectId: await projectId(p),
jobId, jobId,
requestBody: { name, schedule, url, secret, payload, enabled }, requestBody: { name, schedule, url, secret, payload, enabled },
}), }),
@@ -840,7 +904,7 @@ server.registerTool(
}, },
async ({ jobId, projectId: p }) => { async ({ jobId, projectId: p }) => {
try { try {
await AutomationService.deleteScheduledJob({ projectId: projectId(p), jobId }); await AutomationService.deleteScheduledJob({ projectId: await projectId(p), jobId });
return ok({ deleted: jobId }); return ok({ deleted: jobId });
} catch (e) { } catch (e) {
return fail(e); return fail(e);
@@ -861,7 +925,7 @@ server.registerTool(
async ({ jobId, limit, projectId: p }) => { async ({ jobId, limit, projectId: p }) => {
try { try {
return ok( return ok(
await AutomationService.listScheduledJobRuns({ projectId: projectId(p), jobId, limit }), await AutomationService.listScheduledJobRuns({ projectId: await projectId(p), jobId, limit }),
); );
} catch (e) { } catch (e) {
return fail(e); return fail(e);
@@ -877,7 +941,7 @@ server.registerTool(
}, },
async ({ jobId, projectId: p }) => { async ({ jobId, projectId: p }) => {
try { try {
return ok(await AutomationService.runScheduledJob({ projectId: projectId(p), jobId })); return ok(await AutomationService.runScheduledJob({ projectId: await projectId(p), jobId }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -939,20 +1003,65 @@ server.registerTool(
}, },
); );
// Commands that would move a secret into the response are denied outright — // vault_exec allow-list — read/non-destructive commands an agent may run
// the CLI is the broker; the model sees results, not credentials. // under an injected secret. Deny-lists lose (a colon-form `secrets:get`
const EXEC_DENY_FIRST = new Set(["vault", "secrets", "inject", "agent", "login", "logout", "use"]); // already bypassed a pair-match once); only these verbs execute.
const EXEC_DENY_PAIR = new Set(["keys:create"]); // Commands touching host files (upload/download --out, secrets import) and
// anything that moves a secret into output are intentionally absent.
const EXEC_ALLOW = new Set([
"whoami",
"context",
"orgs:list",
"projects:list",
"buckets:list",
"objects:list",
"documents:list",
"jobs:list",
"jobs:runs",
"audit:list",
"keys:list",
"events:send",
]);
// Flags that could redirect output into host files — none of the allowed
// commands need them, so reject the whole set defensively.
const EXEC_ARG_DENY = /^--?(out|file|path|dir|env-file|output|write)\b/;
// Env names that must never carry an injected secret — preload/runtime
// hooks and interpreter variables turn an env injection into code exec
// (NODE_OPTIONS, LD_*, BASH_ENV, PYTHONPATH, …). PRIMORA_* stays allowed:
// the injected VALUE comes from the vault, and those names are its purpose.
const ENV_NAME_DENY =
/^(NODE_|LD_|DYLD_|BASH|ENV$|IFS$|PATH$|HOME$|SHELL$|CDPATH$|SHELLOPTS$|BASHOPTS$|PYTHON|PERL|RUBY|GIT_|SSL_CERT|XDG_)/;
function execAllowed(raw: string[]): { args: string[]; verb: string } {
// Normalize colon-form: ["objects:list","b"] ≡ ["objects","list","b"].
const args = [...raw];
if (args[0].includes(":") && !args[0].startsWith("-")) {
args.splice(0, 1, ...args[0].split(":"));
}
const verb = args.length > 1 && !args[1].startsWith("-") ? `${args[0]}:${args[1]}` : args[0];
if (!EXEC_ALLOW.has(verb)) {
throw new Error(`denied: "${verb}" is not on the vault_exec allow-list (read-only commands only)`);
}
for (const a of args) {
if (a.includes("\0")) throw new Error("denied: NUL byte in argument");
if (a.includes("..") || EXEC_ARG_DENY.test(a)) {
throw new Error(`denied: argument "${a}" could redirect to host files`);
}
}
return { args, verb };
}
server.registerTool( server.registerTool(
"primora_vault_exec", "primora_vault_exec",
{ {
description: description:
"Run a primora CLI command with a vault secret injected into its environment (default secret: PRIMORA_API_KEY). The secret never appears in arguments or the response — only the command output is returned.", "Run a read-only primora CLI command with a vault secret injected into its environment (default secret: PRIMORA_API_KEY). Allow-listed commands only; the secret never appears in arguments or the response.",
inputSchema: { inputSchema: {
command: z command: z
.array(z.string()) .array(z.string())
.describe('primora arguments, e.g. ["objects","list","mybucket"] — vault/secrets/inject/agent/login/logout/use and keys:create are denied'), .describe('primora arguments, e.g. ["objects","list","mybucket"] — allow-listed read commands only'),
secret: z secret: z
.string() .string()
.optional() .optional()
@@ -962,17 +1071,16 @@ server.registerTool(
async ({ command, secret }) => { async ({ command, secret }) => {
try { try {
if (!command.length) throw new Error("empty command"); if (!command.length) throw new Error("empty command");
const first = command[0]; const { args } = execAllowed(command);
const pair = `${first}:${command[1] ?? ""}`;
if (EXEC_DENY_FIRST.has(first) || EXEC_DENY_PAIR.has(pair) || EXEC_DENY_PAIR.has(first)) {
throw new Error(`denied: "${first}" would expose credentials or config`);
}
const secretName = secret ?? "PRIMORA_API_KEY"; const secretName = secret ?? "PRIMORA_API_KEY";
if (!/^[A-Z_][A-Z0-9_]{0,63}$/.test(secretName) || ENV_NAME_DENY.test(secretName)) {
throw new Error(`denied: "${secretName}" is not a safe env name to inject`);
}
const got = runCli(["secrets", "get", secretName]); const got = runCli(["secrets", "get", secretName]);
if (got.code !== 0) { if (got.code !== 0) {
throw new Error(got.stderr.trim() || `vault locked or "${secretName}" missing`); throw new Error(got.stderr.trim() || `vault locked or "${secretName}" missing`);
} }
const r = runCli(command, { [secretName]: got.stdout.trimEnd() }); const r = runCli(args, { [secretName]: got.stdout.trimEnd() });
return ok({ exitCode: r.code, stdout: r.stdout, stderr: r.stderr }); return ok({ exitCode: r.code, stdout: r.stdout, stderr: r.stderr });
} catch (e) { } catch (e) {
return fail(e); return fail(e);
@@ -992,7 +1100,7 @@ server.registerTool(
}, },
async ({ projectId: p }) => { async ({ projectId: p }) => {
try { try {
return ok(await SecretsService.listProjectSecrets({ projectId: projectId(p) })); return ok(await SecretsService.listProjectSecrets({ projectId: await projectId(p) }));
} catch (e) { } catch (e) {
return fail(e); return fail(e);
} }
@@ -1015,7 +1123,7 @@ server.registerTool(
async ({ name, value, url, notes, projectId: p }) => { async ({ name, value, url, notes, projectId: p }) => {
try { try {
const res = await SecretsService.setProjectSecret({ const res = await SecretsService.setProjectSecret({
projectId: projectId(p), projectId: await projectId(p),
name, name,
requestBody: { value, url, notes }, requestBody: { value, url, notes },
}); });
+1 -1
View File
@@ -6,7 +6,7 @@ services:
ports: ports:
- "${MAILPIT_HTTP_PORT}:8025" - "${MAILPIT_HTTP_PORT}:8025"
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8025/ >/dev/null 2>&1 || exit 1"] test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8025/ >/dev/null 2>&1 || exit 1"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 10 retries: 10
+6 -2
View File
@@ -48,6 +48,10 @@ services:
condition: service_started condition: service_started
volumes: volumes:
- backend_storage:/data/storage - backend_storage:/data/storage
# Uncomment for FUNCTIONS_DRIVER=docker — sibling containers for
# sandboxed function runs. Socket access is root-equivalent on the
# host; leave commented unless you run untrusted function code.
# - /var/run/docker.sock:/var/run/docker.sock
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health/liveness || exit 1"] test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health/liveness || exit 1"]
interval: 15s interval: 15s
@@ -100,7 +104,7 @@ services:
auth: auth:
condition: service_started condition: service_started
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/ >/dev/null 2>&1 || exit 1"] test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -120,7 +124,7 @@ services:
volumes: volumes:
- ./infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/nginx-health >/dev/null 2>&1 || exit 1"] test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/nginx-health >/dev/null 2>&1 || exit 1"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 10 retries: 10
+21 -1
View File
@@ -38,7 +38,7 @@
}, },
"apps/cli": { "apps/cli": {
"name": "@primora/cli", "name": "@primora/cli",
"version": "0.5.0", "version": "0.6.0",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.8.1", "@clack/prompts": "^1.8.1",
"@noble/ciphers": "^2.4.0", "@noble/ciphers": "^2.4.0",
@@ -78,6 +78,8 @@
"name": "@primora/frontend", "name": "@primora/frontend",
"version": "0.5.0", "version": "0.5.0",
"dependencies": { "dependencies": {
"@fontsource/geist": "^5.3.0",
"@fontsource/geist-mono": "^5.3.0",
"@primora/api-client": "0.5.0", "@primora/api-client": "0.5.0",
"@primora/shared-types": "0.5.0", "@primora/shared-types": "0.5.0",
"better-auth": "^1.5.6", "better-auth": "^1.5.6",
@@ -2341,6 +2343,24 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fontsource/geist": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/geist/-/geist-5.3.0.tgz",
"integrity": "sha512-u03ftlfVwb05uijq2jGDJ03/HKGqMKaNFk7KOhkpBUex8+BhrAOfMs/XciJ2UUNUX9fEsZbZQld/RWMA2oFU7g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/geist-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/geist-mono/-/geist-mono-5.3.0.tgz",
"integrity": "sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.19.11", "version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more