mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
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
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:
167 files changed
+10022
-257
No files matched your search
@@ -50,6 +50,31 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1
|
||||
BACKEND_INTERNAL_URL=http://backend:8080
|
||||
BACKEND_STORAGE_ROOT=/data/storage
|
||||
|
||||
# Object storage driver: "local" (default, uses BACKEND_STORAGE_ROOT) or "s3".
|
||||
# For s3, point S3_ENDPOINT at any S3-compatible API (AWS, MinIO, Garage, R2).
|
||||
BACKEND_STORAGE_DRIVER=local
|
||||
#S3_ENDPOINT=http://minio:9000
|
||||
#S3_REGION=us-east-1
|
||||
#S3_BUCKET=primora-objects
|
||||
#S3_ACCESS_KEY_ID=
|
||||
#S3_SECRET_ACCESS_KEY=
|
||||
#S3_PREFIX=
|
||||
#S3_PATH_STYLE=true
|
||||
# Client-facing S3 endpoint for presigned URLs — set when S3_ENDPOINT is an
|
||||
# internal address (e.g. http://minio:9000 inside compose) that browsers
|
||||
# can't reach. Presigned URLs are valid only at the host they were minted for.
|
||||
#S3_PUBLIC_ENDPOINT=https://objects.example.com
|
||||
|
||||
# Functions execution. "exec" runs the runtime binary (bun/deno) directly on
|
||||
# the backend host — simple, but function code inherits backend privileges.
|
||||
# "docker" runs each invoke in an ephemeral locked-down container (no
|
||||
# network, read-only fs, 128 MiB mem, non-root) and requires the Docker
|
||||
# socket mounted into the backend plus the runtime images pulled.
|
||||
#FUNCTIONS_DRIVER=exec
|
||||
#FUNCTIONS_RUNTIME=auto
|
||||
#FUNCTIONS_TIMEOUT_SECONDS=30
|
||||
#FUNCTIONS_MAX_OUTPUT_BYTES=65536
|
||||
|
||||
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
|
||||
# values win over these env vars; deleting the in-app value reverts here.
|
||||
RESEND_API_KEY=
|
||||
|
||||
@@ -146,6 +146,11 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Build Tauri bundles
|
||||
# Updater artifacts are minisign-signed; without the key the build
|
||||
# still succeeds but emits no .sig files (updater can't verify them).
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: npm run bundle --workspace @primora/desktop
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -155,8 +160,11 @@ jobs:
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.deb
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.rpm
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.dmg
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.app.tar.gz
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.msi
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.exe
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.nsis.zip
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.sig
|
||||
if-no-files-found: error
|
||||
|
||||
npm-packages:
|
||||
@@ -225,6 +233,48 @@ jobs:
|
||||
flag
|
||||
' CHANGELOG.md > notes.md
|
||||
[ -s notes.md ] || echo "Release ${VERSION}" > notes.md
|
||||
# Tauri updater manifest: one platform entry per signed updater
|
||||
# artifact (AppImage.sig / app.tar.gz.sig / nsis.zip.sig). Missing
|
||||
# sigs mean TAURI_SIGNING_PRIVATE_KEY isn't set — then there is no
|
||||
# manifest and no update, which is correct (never ship unsigned).
|
||||
- name: Build updater manifest (latest.json)
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, glob
|
||||
base = "https://github.com/Dvorinka/Primora/releases/download/%s" % os.environ["VERSION"]
|
||||
platforms = {}
|
||||
for sig in glob.glob("dist-release/**/*.sig", recursive=True):
|
||||
asset = sig[:-4]
|
||||
name = os.path.basename(asset)
|
||||
if name.endswith(".AppImage"):
|
||||
plat = "linux-x86_64"
|
||||
elif name.endswith(".app.tar.gz"):
|
||||
plat = "darwin-aarch64" if "aarch64" in name else "darwin-x86_64"
|
||||
if "aarch64" not in name and "x86_64" not in name:
|
||||
plat = "darwin-aarch64" # macos-latest is arm64
|
||||
elif name.endswith(".nsis.zip"):
|
||||
plat = "windows-x86_64"
|
||||
else:
|
||||
continue
|
||||
platforms[plat] = {
|
||||
"signature": open(sig).read().strip(),
|
||||
"url": f"{base}/{name}",
|
||||
}
|
||||
manifest = {
|
||||
"version": os.environ["VERSION"],
|
||||
"notes": "See the release notes.",
|
||||
"pub_date": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"platforms": platforms,
|
||||
}
|
||||
if platforms:
|
||||
with open("dist-release/latest.json", "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
print("latest.json:", ", ".join(platforms))
|
||||
else:
|
||||
print("no signed updater artifacts — skipping latest.json")
|
||||
PY
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -234,6 +284,54 @@ jobs:
|
||||
mapfile -t files < <(find dist-release -type f)
|
||||
[ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; }
|
||||
gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}"
|
||||
# The manifest must be fetchable at /releases/latest/download/latest.json
|
||||
if [ -f dist-release/latest.json ]; then
|
||||
gh release upload "$VERSION" dist-release/latest.json --clobber
|
||||
fi
|
||||
|
||||
# Software bill of materials — CycloneDX JSON per published image plus
|
||||
# one for the source tree (npm + Go modules). Attached to the release
|
||||
# next to the binaries; downstream consumers can verify provenance.
|
||||
sbom:
|
||||
name: SBOMs
|
||||
needs: [verify, release]
|
||||
if: needs.verify.outputs.needed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
env:
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: anchore/sbom-action@v0
|
||||
with:
|
||||
image: ghcr.io/${{ github.repository_owner }}/primora-backend:${{ needs.verify.outputs.version }}
|
||||
format: cyclonedx-json
|
||||
output-file: dist-release/sbom-backend.cdx.json
|
||||
- uses: anchore/sbom-action@v0
|
||||
with:
|
||||
image: ghcr.io/${{ github.repository_owner }}/primora-auth:${{ needs.verify.outputs.version }}
|
||||
format: cyclonedx-json
|
||||
output-file: dist-release/sbom-auth.cdx.json
|
||||
- uses: anchore/sbom-action@v0
|
||||
with:
|
||||
image: ghcr.io/${{ github.repository_owner }}/primora-frontend:${{ needs.verify.outputs.version }}
|
||||
format: cyclonedx-json
|
||||
output-file: dist-release/sbom-frontend.cdx.json
|
||||
- uses: anchore/sbom-action@v0
|
||||
with:
|
||||
path: .
|
||||
format: cyclonedx-json
|
||||
output-file: dist-release/sbom-source.cdx.json
|
||||
- name: Attach SBOMs to the release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "$VERSION" dist-release/sbom-*.cdx.json --clobber
|
||||
|
||||
npm-publish:
|
||||
name: Publish to npm
|
||||
|
||||
+34
-1
@@ -11,14 +11,47 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
|
||||
- **`primora agent` credential broker** — wraps a child process with dummy tokens and a loopback base URL; the proxy attaches the real secret on the wire per grant (TTL, cap 3600 s). Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`, `github`, `npm`; `--upstream name=https://…` covers arbitrary Bearer APIs. Base-URL broker, not TLS MITM — tools must honor a base-URL override.
|
||||
- **`primora inject`** — runs a child process with vault secrets in its environment (`--all`, or `--env-file` templates containing `primora://NAME` refs). Nothing on disk, nothing in argv.
|
||||
- **`primora stack:*` operator group** — `up`, `down`, `status`, `logs [service]`, `pull`, `backup` wrap `docker compose` in `--dir` (default cwd). `stack up --vault` materializes `.env` from the vault for the duration of the run (refuses to clobber an existing `.env` without `--force`, which swaps and restores it). `stack backup` writes a `pg_dump -Fc` of the platform database to `./backups`.
|
||||
- **Vault MCP tools** (`@primora/mcp`) — `primora_vault_status`, `primora_vault_list` (metadata only — values are never returned), and `primora_vault_exec` which runs `primora` commands with a secret injected into the child environment; `vault`/`secrets`/`inject`/`agent`/`login`/`logout`/`use` and `keys:create` are denied so no response can carry a credential.
|
||||
- **Vault MCP tools** (`@primora/mcp`) — `primora_vault_status`, `primora_vault_list` (metadata only — values are never returned), and `primora_vault_exec` which runs `primora` commands with a secret injected into the child environment; `vault`/`secrets`/`inject`/`agent`/`login`/`logout`/`use` and `keys:create` are denied so no response can carry a credential. (Hardened to a strict allow-list — see Security.)
|
||||
- **Secrets Vault window in the desktop app** (`@primora/desktop`) — tray "Secrets Vault…" opens a dedicated window (`ui/vault.html`) covering init, timed unlock/lock with live countdown, secret list with copy-to-clipboard (values never displayed), add/delete, and `.env` import. IPC shells out to the `primora` binary (`PRIMORA_CLI` overrides), so desktop and terminal share one vault file and one crypto implementation; a missing CLI shows install guidance. Also reachable from the connect screen.
|
||||
- **Project vault — server-side secrets** (`core.project_secrets`, migration 00011) — per-project secrets encrypted AES-256-GCM at rest via the existing encryptor. API: `GET/PUT/DELETE /projects/:projectID/secrets[/:name]` plus `POST …/reveal`. Lists return metadata only (name, url, notes, timestamps); reveal decrypts explicitly and is audit-logged (`secret.set`/`secret.revealed`/`secret.deleted`). Scheduled-job payloads reference secrets as `secret://NAME`, resolved to plaintext at delivery — refs are stored, values are not; unresolved or unconfigured refs fail the run loudly.
|
||||
- **Vault page in the dashboard** — project secrets table with create/update, `.env` import, delete, and copy-to-clipboard reveal; plus a "This machine" card when running inside the desktop shell that drives the local vault through the same Tauri IPC commands as the vault window.
|
||||
- **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values.
|
||||
- **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext.
|
||||
- **Realtime channel subscriptions** (`@primora/client`) — `createRealtimeClient({ endpoint, key, projectId })` opens the project SSE stream (`/realtime/stream`) via fetch + ReadableStream (EventSource can't send `X-API-Key`). `channel("documents").on("created", cb)` subscribes by event namespace (`document`/`object`/`issue`/`deploy`, `"*"` for all); auto-reconnects with capped exponential backoff; `onStateChange` reports `connecting|open|reconnecting|closed`.
|
||||
- **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components.
|
||||
- **Inbound webhooks** (`core.inbound_hooks`, migration 00013) — public ingest endpoints `POST /api/v1/hooks/<token>` (the token is the credential). `event` mode republishes the body as `inbound.received` domain events through the signed dispatcher + realtime; `job` mode triggers a scheduled-job run with the received body as its payload (`triggered_by: "hook"`). Optional `secret` requires `X-Primora-Signature` HMAC-SHA256 — the same scheme outbound deliveries use. Bodies capped at 256 KiB. Managed from Integrations → Webhooks.
|
||||
- **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection <slug> --filter … --order …`.
|
||||
- **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test.
|
||||
- **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations.
|
||||
- **S3-compatible object storage backend** — `BACKEND_STORAGE_DRIVER=s3` swaps the local disk store for any S3-compatible API (AWS, MinIO, Garage, R2) via a stdlib-only SigV4 implementation — no AWS SDK dependency. `S3_ENDPOINT`/`S3_REGION`/`S3_BUCKET`/credentials configure it; `S3_PATH_STYLE` (default on) covers MinIO/Garage, off for AWS virtual-hosted. The configured bucket is created on startup if missing; objects stream with computed SHA-256, `storage_path` records `s3://bucket/key`. Verified live end-to-end (bucket → put → get → copy → list → delete).
|
||||
- **Functions** (`core.functions` + `core.function_runs`, migration 00015) — project-scoped JS/TS executed by a runtime binary on the backend host (`bun` or `deno`, per function). The JSON payload arrives on stdin and as `PRIMORA_PAYLOAD`; stdout/stderr are captured per run (capped at 64 KiB) with status, exit code and duration — `FUNCTIONS_RUNTIME`/`FUNCTIONS_TIMEOUT_SECONDS`/`FUNCTIONS_MAX_OUTPUT_BYTES` configure the runner. API: CRUD under `/projects/:id/functions`, `POST …/invoke` (502 + recorded run on failure), `GET …/runs`. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Code executes with backend privileges — the documented self-hosted trust model.
|
||||
- **Presigned object URLs** — `POST /buckets/:bucketID/object-presigns` mints SigV4 query-signed `GET`/`PUT` URLs (60 s–1 h TTL) so clients transfer large objects directly against S3 without proxying through the backend. Read role for downloads (object must exist — no key probing), write role for uploads. `S3_PUBLIC_ENDPOINT` rewrites the client-facing host for internal `S3_ENDPOINT`s; signatures bind the host so URLs are valid only where they were minted. The local driver returns a clear error — a presigned URL needs a real object API.
|
||||
- **Desktop auto-updater** — `tauri-plugin-updater` wired end to end: minisign keypair, `createUpdaterArtifacts`, `latest.json` manifest built from signed artifacts on every release, and a silent background check on the connect screen offering "Update & restart" (download → verify → install → relaunch). Unsigned artifacts can never install — missing signature means no manifest, not a bypass. CI signs via `TAURI_SIGNING_PRIVATE_KEY` secrets; OS-level signing (notarization/Authenticode) remains open.
|
||||
- **Release SBOMs** — CycloneDX SBOM for each published GHCR image (backend/auth/frontend) plus one for the source tree, attached to every GitHub Release via `anchore/sbom-action`.
|
||||
- **Sandboxed function driver** — `FUNCTIONS_DRIVER=docker` runs every invoke in an ephemeral locked-down container: no network, read-only rootfs, 128 MiB memory, 0.5 CPU, 64 pids, non-root, all capabilities dropped. Source is injected via base64 env (no bind mount — works on Docker Desktop and remote daemons); timeout force-removes the container so nothing lingers. Verified live: code runs, network is unreachable inside, and deadline kills land.
|
||||
- **Client events + presence** — `POST /projects/:id/events` publishes client-authored events (`custom.*` namespace enforced — system types can't be spoofed) through the same fan-out as system events: realtime subscribers, matching webhooks, and `event_pattern` functions all fire. Realtime now tracks presence: `presence.update` {online} broadcasts on every subscribe/unsubscribe (stream-only — never reaches webhooks or functions), `GET …/realtime/presence` reads the count. Client SDK gains `publish()` and `presence()`; CLI gains `events:send`.
|
||||
- **Function triggers** (migration 00016) — three new ways to run a function, all through the same executor and run log. Scheduled jobs accept `function_id` instead of `url` (run status mirrors the exit code); inbound hooks gain `mode: "function"` — the received body becomes the function's stdin payload, acked immediately with the run recorded async; and `event_pattern` on a function (`document.*`, `*`, or an exact type) fires it detached whenever a matching domain event publishes — `{event, project_id, occurred_at, data}` as payload. Runs record trigger source: `manual`/`schedule`/`hook`/`event`. Dashboard: function event-trigger field, job target picker (URL or function), and inbound-hook function mode.
|
||||
- **`GET /api/v1/context`** — actor self-discovery: for API keys returns scopes, key prefix, and the resolved organization + project (id/slug/name). Fixes the API-key onboarding dead end: `primora login --api-key` now validates the key against the instance before saving and seeds org/project context automatically; `primora use`, `context`, and `whoami` work under API-key auth via this endpoint.
|
||||
- **CLI `orgs:rm` / `projects:rm` / `buckets:rm`** — destructive deletes gated behind `--yes` in non-interactive mode, interactive confirm otherwise. `buckets:rm` resolves id/slug/name.
|
||||
- **Webhook wildcard subscriptions** — `events` accepts `*`, `<namespace>.*`, and `custom.*` patterns, matched at dispatch time via SQL `LIKE` — the same semantics function `event_pattern` already used. Client-published `custom.*` events can now be webhooked.
|
||||
|
||||
### Security
|
||||
|
||||
- **`primora_vault_exec` rewritten as an allow-list** — the deny-list was bypassed by colon-form commands (`["secrets:get","X"]` as a single token evaded the pair check), exposing every vault secret; `--remote` variants revealed project secrets; and the `secret` param was used verbatim as an env name, allowing `NODE_OPTIONS=--require <file>` host code execution. Now only a fixed read-mostly command set runs (`whoami`, `context`, `orgs:list`, `projects:list`, `buckets:list`, `objects:list`, `documents:list`, `jobs:list`, `jobs:runs`, `audit:list`, `keys:list`, `events:send`), path/file flags and `..` args are rejected, injected env names must match `^[A-Z_][A-Z0-9_]{0,63}$`, and loader-hook names (`NODE_*`, `LD_*`, `DYLD_*`, `PATH`, `BASH*`, `PYTHON*`, `GIT_*`, `SSL_CERT*`, `XDG_*`, …) are blocked. Verified: every prior bypass denied, allowed commands still inject.
|
||||
- **SSRF hardening on webhook/job URLs** — cloud metadata endpoints (`169.254.169.254`, `fd00:ec2::254`) are excluded from the link-local/private allowance, and pure decimal/hex "bare hostnames" (`2130706433`, `0x7f000001` — non-canonical IP encodings of loopback) are no longer treated as Docker service names.
|
||||
- **Path params now `encodeURIComponent`** in all generated-client consumers (CLI, MCP, dashboard) — `encodeURI` left `?#&` intact, smuggling query segments through `{name}` params (e.g. `secrets/a?reveal=1`).
|
||||
- **Strict base64 in `primora_upload_object`** — `Buffer.from(b64)` silently drops invalid characters; `contentBase64` is now validated (whitespace-tolerant) so corrupt input can't silently store garbage bytes.
|
||||
- **MCP no-creds startup** — a missing config/API key previously threw inside `configureClient`, killing the process mid-handshake (protocol hang, stack on stderr). Startup now degrades: `initialize`/`tools/list` answer normally and every tool call returns the actionable error.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Silent CLI dispatch** — unknown commands, a bare `primora`, and group names without a verb exited 0 with no output. Now: unknown input errors with exit 1 (bare groups list their verbs), bare `primora` prints help.
|
||||
- **Arg-parser numeric coercion** — `cac`/`mri` coerced numeric-looking flag values (`--value "007"` → `7`, `--value ""` → `0`), corrupting secrets and keys. Parsed options are now re-read as raw strings from argv for every non-boolean flag; `--value 007` round-trips intact.
|
||||
- **`pgx.ErrNoRows` → 404** in the shared error mapper — missing resources returned raw "no rows in result set" 500s across all handlers.
|
||||
- **Structured error hygiene** — `pgconn.PgError` codes map to clean statuses (23505 → 409 "resource already exists", 23503 → 409, 22001/54000 → 400 "value too large", 22021 → 400) instead of leaking SQLSTATE/constraint names; validator failures report `field is required`/`field failed <tag> validation`; a new `services.InputError` type maps service-layer input failures to 400 (webhook URL/event-type errors were 500s). Presign on the local driver returns `presign_unsupported` 400.
|
||||
- **`fetch failed` polish** — connection failures report the cause code and suggest `primora context` instead of bare undici errors.
|
||||
- **`login --json`** — the flag now emits JSON on both the session and API-key paths.
|
||||
- **Dashboard fonts vendored** — Geist/Geist Mono now ship via `@fontsource` inside the bundle instead of a Google Fonts `<link>` the nginx CSP blocked (`style-src 'self'`). Removes the runtime Google dependency, works airgapped, and eliminates the console CSP violation.
|
||||
|
||||
## [0.7.1] - 2026-09-23
|
||||
|
||||
|
||||
+29
-5
@@ -41,7 +41,14 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
| `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
|
||||
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
|
||||
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
|
||||
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container | yes |
|
||||
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container (local driver) | yes |
|
||||
| `BACKEND_STORAGE_DRIVER` | `local` (default) or `s3` — S3-compatible object storage | no |
|
||||
| `S3_ENDPOINT` / `S3_REGION` / `S3_BUCKET` | S3 API endpoint (empty endpoint → AWS `s3.<region>.amazonaws.com`), region, bucket | with `s3` |
|
||||
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 credentials | with `s3` |
|
||||
| `S3_PREFIX` / `S3_PATH_STYLE` | optional key prefix; path-style URLs (default `true`, needed by MinIO/Garage — set `false` for AWS virtual-hosted) | no |
|
||||
| `S3_PUBLIC_ENDPOINT` | client-facing S3 endpoint used for presigned URLs when `S3_ENDPOINT` is internal-only (e.g. `http://minio:9000`); signatures bind the public host | no |
|
||||
| `FUNCTIONS_DRIVER` | Function execution driver: `exec` (default — runtime binary on the backend host; code runs with backend privileges) or `docker` — each invoke runs in an ephemeral locked-down container (no network, read-only fs, 128 MiB, non-root). Docker mode needs `/var/run/docker.sock` mounted into the backend (see the commented volume in `docker-compose.yml`) and the runtime images pulled (`oven/bun`, `denoland/deno`). Source code is capped at 256 KiB under the docker driver | no |
|
||||
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` / `FUNCTIONS_MAX_OUTPUT_BYTES` | Functions: runtime binary (`auto` resolves `bun`/`deno` — exec driver only; docker driver uses the matching official image), per-run timeout (default 30 s), captured-output cap (default 64 KiB) | no |
|
||||
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
|
||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
|
||||
@@ -127,10 +134,27 @@ docker compose up -d # 3. rolling recreate; backend migrate
|
||||
|
||||
## Storage constraint
|
||||
|
||||
Object storage is the **local filesystem** (`backend_storage` volume). There
|
||||
is no S3-compatible backend yet — the volume must be on durable, backed-up
|
||||
disk, and a single host serves all objects. Plan capacity accordingly;
|
||||
multi-node deployments need an external object store first.
|
||||
Object storage defaults to the **local filesystem** (`backend_storage`
|
||||
volume) — durable, backed-up disk on a single host. For multi-node or
|
||||
external object stores set `BACKEND_STORAGE_DRIVER=s3` + the `S3_*`
|
||||
variables above (AWS, MinIO, Garage, R2 all work).
|
||||
|
||||
## Functions trust model
|
||||
|
||||
Two drivers, two trust levels:
|
||||
|
||||
- **`exec` (default)** — function code runs as a child process of the
|
||||
backend: same host, same privileges, no sandbox. Treat every project
|
||||
member who can edit a function as holding a shell on the backend host.
|
||||
Appropriate for single-team self-hosted deployments.
|
||||
- **`docker`** — each invoke runs in an ephemeral container: no network,
|
||||
read-only rootfs, 128 MiB memory, half a CPU, 64 pids, non-root, all
|
||||
capabilities dropped. Requires the Docker socket mounted into the
|
||||
backend (commented volume in `docker-compose.yml`) — note the socket is
|
||||
itself root-equivalent on the host, so this isolates *function code*,
|
||||
not operators. Source code capped at 256 KiB. For hostile multi-tenant
|
||||
workloads, a dedicated microVM runtime (Firecracker/gVisor) remains the
|
||||
roadmap item.
|
||||
|
||||
## Observability
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@ The important ones:
|
||||
| `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app |
|
||||
| `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only |
|
||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app |
|
||||
| `BACKEND_STORAGE_DRIVER` | `local` (default, `BACKEND_STORAGE_ROOT`) or `s3` — S3-compatible object storage |
|
||||
| `S3_ENDPOINT` / `S3_BUCKET` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 backend config (with `S3_REGION`, `S3_PREFIX`, `S3_PATH_STYLE`) |
|
||||
| `S3_PUBLIC_ENDPOINT` | Client-facing S3 host for presigned URLs when `S3_ENDPOINT` is internal-only |
|
||||
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` | JS runtime for Functions — `auto` (default, resolves `bun`/`deno` from PATH) or a binary path; 30 s default timeout |
|
||||
| `VITE_DEMO_MODE` | Enable the client-side demo workspace |
|
||||
|
||||
### Instance administration
|
||||
|
||||
@@ -48,8 +48,12 @@ Supabase, with no hosted tier - your data stays on your hardware.
|
||||
|
||||
- **Organizations & projects** - workspaces with scoped org and project roles.
|
||||
- **Auth** - email/password plus optional GitHub, Google, Discord, and Microsoft OAuth. JWTs verified by the API against JWKS.
|
||||
- **Storage** - S3-style buckets on your local filesystem, public or private.
|
||||
- **Collections** - schema-flexible JSON documents in Postgres JSONB.
|
||||
- **Storage** - S3-style buckets on local disk or any S3-compatible backend (AWS, MinIO, Garage, R2), public or private. With `s3` the API mints presigned upload/download URLs so large objects skip the backend.
|
||||
- **Collections** - schema-flexible JSON documents in Postgres JSONB, with filter/order queries.
|
||||
- **Functions** - project-scoped JS/TS run by bun/deno — on the host (`exec`) or in ephemeral locked-down containers (`docker` driver) — with per-run logs. Trigger them four ways: manual invoke, cron schedules (`function_id` on a job), inbound hooks (`mode: "function"`), or domain events (`event_pattern` like `document.*`).
|
||||
- **Vault** - AES-256-GCM project secrets plus an Argon2id/XChaCha20 local vault in the CLI and desktop app.
|
||||
- **Automation** - scheduled jobs, inbound webhooks, telemetry alert rules, and `secret://` refs resolved at delivery.
|
||||
- **Realtime** - SSE event stream with a typed client SDK (`@primora/client`) — channel subscriptions, presence counts, and client-published `custom.*` events that fan out to webhooks and functions.
|
||||
- **API keys** - `prm_<prefix>_<secret>` credentials, shown once.
|
||||
- **Audit log** - every mutating request recorded, with CSV/JSON export.
|
||||
- **Demo mode** - fully client-side seeded workspace via `?demo=true`.
|
||||
|
||||
+116
-23
@@ -123,8 +123,11 @@ Follow-ups:
|
||||
|
||||
- Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run
|
||||
(host-verified on Linux only).
|
||||
- Desktop bundle signing + auto-updater — unsigned artifacts today; add when
|
||||
distribution matures.
|
||||
- ~~Desktop bundle signing + auto-updater~~ ✅ — `tauri-plugin-updater`
|
||||
wired: signed updater artifacts (`createUpdaterArtifacts`), minisign
|
||||
keypair, `latest.json` manifest assembled on release, in-app
|
||||
"Update & restart" on the connect screen. macOS/Windows OS-level code
|
||||
signing (notarization/Authenticode) still open — needs paid certs.
|
||||
|
||||
## Phase 6 — Production hardening (v0.6.0) ✅
|
||||
|
||||
@@ -185,8 +188,9 @@ release pipeline with images + binaries + desktop bundles.
|
||||
(auto-registers from ingest).
|
||||
- ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres →
|
||||
dragonfly transfer, key templating, TTL).
|
||||
- ~~**External object storage**~~ ✅ — constraint documented in
|
||||
DEPLOYMENT_GUIDE.md / PRODUCTION_READINESS.md; S3 backend deferred.
|
||||
- ~~**External object storage**~~ ✅ — `BACKEND_STORAGE_DRIVER=s3` shipped
|
||||
(Phase 10): any S3-compatible API + presigned URLs; local disk remains
|
||||
the default.
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -205,8 +209,10 @@ release pipeline with images + binaries + desktop bundles.
|
||||
- ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via
|
||||
QEMU + buildx.
|
||||
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle
|
||||
verification, signing + auto-updater.
|
||||
- Optional: SBOM + provenance attestation on release images.
|
||||
verification; updater + minisign signing shipped (OS-level signing
|
||||
remains open).
|
||||
- ~~Optional: SBOM~~ ✅ — CycloneDX SBOMs per published image + source
|
||||
tree, attached to every release (`anchore/sbom-action`).
|
||||
|
||||
### Docs
|
||||
|
||||
@@ -304,28 +310,115 @@ Channel subscriptions over the Phase 7 SSE stream, in `@primora/client`.
|
||||
with capped backoff, `onStateChange` lifecycle.
|
||||
- `channel("documents").on("created", cb)` — namespaces `document`, `object`,
|
||||
`issue`, `deploy`, `"*"` for everything; trailing `s` optional.
|
||||
- Presence and client-broadcast deliberately deferred — protocol question,
|
||||
no consumer yet.
|
||||
- **Presence** ✅ — server tracks realtime subscribers per project;
|
||||
`presence.update` {online: n} broadcasts on every join/leave (realtime-only,
|
||||
never fans out to webhooks/functions), `GET …/realtime/presence` reads the
|
||||
count, `client.presence()` wraps it.
|
||||
- **Client events** ✅ — `POST /projects/:id/events` publishes `custom.*`
|
||||
types through the standard fan-out (realtime + webhooks + functions);
|
||||
`client.publish("custom.x", data)` and `primora events:send` both reach it.
|
||||
The `custom.*` prefix is enforced so clients can't spoof system events.
|
||||
|
||||
## Notifications — alert rules (unreleased)
|
||||
|
||||
Telemetry-driven alerts delivered through the existing webhook dispatcher.
|
||||
|
||||
- `core.alert_rules` (migration 00012) — `heartbeat_silence` and
|
||||
`error_spike` kinds, per-component or project-wide scope, evaluated by a
|
||||
60 s backend ticker.
|
||||
- Transition-based firing: `alert.fired` once on breach, `alert.resolved`
|
||||
on recovery — no notification storms. Both are webhook event types and
|
||||
realtime events, so any existing subscriber receives them signed.
|
||||
- CRUD via `/projects/:id/alerts`; Integrations page manages rules next to
|
||||
webhooks and shows live `firing` state.
|
||||
|
||||
## Inbound webhooks (unreleased)
|
||||
|
||||
Project-scoped public ingest for external systems.
|
||||
|
||||
- `POST /api/v1/hooks/<token>` — token is the credential; optional
|
||||
`X-Primora-Signature` HMAC-SHA256 verification when a secret is set.
|
||||
- `event` mode republishes the body as `inbound.received` (webhooks +
|
||||
realtime fan out for free); `job` mode enqueues a scheduled-job run with
|
||||
the received body as payload (`triggered_by: "hook"`).
|
||||
- 256 KiB body cap; `last_received_at` tracked; managed from Integrations.
|
||||
|
||||
## HA scheduler (unreleased)
|
||||
|
||||
Multi-replica safety for the job scheduler and alert evaluator.
|
||||
|
||||
- PostgreSQL advisory locks (session-scoped, separate keys per component)
|
||||
on a dedicated pinned pool connection; only the leader scans for due work.
|
||||
- Manual runs and hook-triggered runs work on any replica — the lock gates
|
||||
only the scheduled scan, so leadership loss never blocks on-demand work.
|
||||
- Released on shutdown; failover verified with a live two-replica test —
|
||||
the survivor acquired leadership and fired the due job.
|
||||
|
||||
## Email surface (unreleased)
|
||||
|
||||
Reusable templates + an operational send log.
|
||||
|
||||
- `core.email_log` (migration 00014) — every send recorded: template,
|
||||
recipient, subject, `sent`/`failed` status, error text.
|
||||
- `Mailer` stays transport-only (Resend when configured, SMTP otherwise);
|
||||
templates render subject + plaintext body. First template: `invitation`.
|
||||
- `GET /projects/:id/emails` lists recent sends; Members page shows an
|
||||
"Email log" card under pending invitations.
|
||||
|
||||
## Future phases — candidates
|
||||
|
||||
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
||||
|
||||
- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers.
|
||||
Deno or Bun isolates in a sidecar; secrets via env, payload via stdin.
|
||||
The job runner already supplies the scheduling half.
|
||||
- **Phase 10 — Storage backends** — S3-compatible object storage behind
|
||||
the existing bucket abstraction (deferred from Phase 6), plus
|
||||
presigned-URL uploads for large files.
|
||||
- **Notifications** — alert rules on telemetry (error-rate thresholds,
|
||||
heartbeat silence) fanning out through the webhook dispatcher.
|
||||
- **Inbound webhooks** — project-scoped ingest endpoints that turn
|
||||
external events into domain events/jobs.
|
||||
- **HA mode** — leader-elected scheduler + distributed locks so two
|
||||
backend replicas don't double-fire jobs; Postgres advisory locks are
|
||||
enough at this scale.
|
||||
- **Email surface** — transactional templates + send log in the
|
||||
dashboard, backed by the auth service's existing SMTP/Resend wiring.
|
||||
- **Phase 9 — Functions** — ✅ shipped: `core.functions` +
|
||||
`core.function_runs`, bun/deno exec runner (stdin payload, env injection,
|
||||
capped output, timeout), invoke API + dashboard page. Triggers shipped:
|
||||
scheduled jobs (`function_id` on a job), inbound hooks (`mode:
|
||||
"function"`), and domain events (`event_pattern`, e.g. `document.*`).
|
||||
Runs record trigger source (`manual`/`schedule`/`hook`/`event`).
|
||||
Isolation shipped: `FUNCTIONS_DRIVER=docker` runs every invoke in an
|
||||
ephemeral locked-down container (no network, read-only fs, non-root).
|
||||
Remaining candidate: Firecracker/gVisor microVMs for hostile
|
||||
multi-tenant workloads — docker socket trust is documented.
|
||||
- **Phase 10 — Storage backends** — ✅ shipped: `BACKEND_STORAGE_DRIVER=s3`
|
||||
+ `S3_*` env vars, stdlib SigV4, verified against a real S3 server.
|
||||
Presigned upload/download URLs shipped: `POST
|
||||
/buckets/:id/object-presigns` mints SigV4 query-signed GET/PUT URLs
|
||||
(`S3_PUBLIC_ENDPOINT` rewrites the client-facing host); local driver
|
||||
returns a clear error — a presigned URL needs a real object API behind it.
|
||||
- **HA mode** — ✅ shipped: PostgreSQL advisory-lock leadership for the
|
||||
scheduler and alert evaluator; failover verified with two replicas.
|
||||
- **Email surface** — ✅ shipped: templated sends + `core.email_log` +
|
||||
dashboard card; more templates land as new flows need them.
|
||||
|
||||
## Release hardening (unreleased)
|
||||
|
||||
- **Desktop auto-updater** — `tauri-plugin-updater`: signed updater
|
||||
artifacts, `latest.json` manifest assembled per release, in-app
|
||||
"Update & restart" on the connect screen. Unsigned artifacts can never
|
||||
install — no signature means no manifest.
|
||||
- **SBOMs** — CycloneDX per GHCR image + source tree, attached to every
|
||||
GitHub Release.
|
||||
- Requires `TAURI_SIGNING_PRIVATE_KEY` repo secret (keypair generated
|
||||
locally; private half never committed).
|
||||
|
||||
## Sandboxed functions (unreleased)
|
||||
|
||||
- `FUNCTIONS_DRIVER=docker` — every invoke runs in an ephemeral sibling
|
||||
container: `--network none`, read-only rootfs, 128 MiB / 0.5 CPU / 64
|
||||
pids, non-root, `--cap-drop ALL`. Source travels as base64 env — no
|
||||
bind mount, so Docker Desktop and remote daemons work. Timeout
|
||||
force-removes the named container.
|
||||
- `exec` remains the default for single-team deployments; the trust-model
|
||||
doc describes both levels.
|
||||
|
||||
## Client events + presence (unreleased)
|
||||
|
||||
- `POST /projects/:id/events` — members/API keys publish `custom.*`
|
||||
events through the standard fan-out (realtime + webhooks + functions);
|
||||
system types can't be spoofed.
|
||||
- Presence — per-project realtime subscriber counts; `presence.update`
|
||||
broadcasts on join/leave (stream-only), `GET …/realtime/presence`
|
||||
reads the count. SDK: `publish()` + `presence()`; CLI: `events:send`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ RUN apt-get update \
|
||||
&& chmod +x /usr/local/bin/dbx-mcp \
|
||||
&& rm /tmp/dbx-mcp.tar.gz
|
||||
|
||||
# Functions runtime — bun static binary from the official image (multi-arch).
|
||||
COPY --from=oven/bun:1.3.14 /usr/local/bin/bun /usr/local/bin/bun
|
||||
|
||||
COPY --from=builder /primora-backend /usr/local/bin/primora-backend
|
||||
COPY --from=builder /app/db ./db
|
||||
COPY --from=builder /app/openapi ./openapi
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 = '*');
|
||||
@@ -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;
|
||||
@@ -17,8 +17,9 @@ INSERT INTO core.scheduled_jobs (
|
||||
payload,
|
||||
enabled,
|
||||
next_run_at,
|
||||
created_by_user_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
created_by_user_id,
|
||||
function_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateScheduledJob :one
|
||||
@@ -30,6 +31,7 @@ SET name = $3,
|
||||
next_run_at = $7,
|
||||
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
|
||||
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
|
||||
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND project_id = $2
|
||||
|
||||
@@ -147,3 +147,7 @@ WHERE p.organization_id = $1;
|
||||
DELETE FROM core.organizations
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetOrganizationByID :one
|
||||
SELECT * FROM core.organizations
|
||||
WHERE id = $1;
|
||||
@@ -11,7 +11,10 @@ WHERE id = $1;
|
||||
SELECT * FROM core.webhooks
|
||||
WHERE project_id = $1
|
||||
AND enabled
|
||||
AND (cardinality(events) = 0 OR $2::text = ANY(events));
|
||||
AND (cardinality(events) = 0 OR EXISTS (
|
||||
SELECT 1 FROM unnest(events) AS e
|
||||
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
|
||||
));
|
||||
|
||||
-- name: CreateWebhook :one
|
||||
INSERT INTO core.webhooks (
|
||||
|
||||
@@ -81,9 +81,31 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
||||
}
|
||||
}
|
||||
|
||||
store, err := storage.NewLocalStore(cfg.StorageRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var store storage.Store
|
||||
if cfg.StorageDriver == "s3" {
|
||||
s3Store, err := storage.NewS3Store(storage.S3Config{
|
||||
Endpoint: cfg.S3Endpoint,
|
||||
Region: cfg.S3Region,
|
||||
Bucket: cfg.S3Bucket,
|
||||
AccessKeyID: cfg.S3AccessKeyID,
|
||||
SecretAccessKey: cfg.S3SecretAccessKey,
|
||||
PublicEndpoint: cfg.S3PublicEndpoint,
|
||||
Prefix: cfg.S3Prefix,
|
||||
PathStyle: cfg.S3PathStyle,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("s3 storage: %w", err)
|
||||
}
|
||||
if err := s3Store.EnsureBucket(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store = s3Store
|
||||
} else {
|
||||
localStore, err := storage.NewLocalStore(cfg.StorageRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store = localStore
|
||||
}
|
||||
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
|
||||
repo := repositories.NewCoreRepository(dbPool)
|
||||
@@ -91,7 +113,13 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encryption key: %w", err)
|
||||
}
|
||||
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger)
|
||||
var fnRunner services.FunctionRunner
|
||||
if cfg.FunctionsDriver == "docker" {
|
||||
fnRunner = services.NewDockerRunner(cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
|
||||
} else {
|
||||
fnRunner = services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
|
||||
}
|
||||
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, fnRunner)
|
||||
settings := services.NewSettingsService(repo, encryptor)
|
||||
|
||||
if cfg.Env == "production" {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -35,6 +36,26 @@ type Config struct {
|
||||
SMTPSecure bool
|
||||
|
||||
EncryptionKey string
|
||||
|
||||
// Object storage: "local" (BACKEND_STORAGE_ROOT) or "s3".
|
||||
StorageDriver string
|
||||
S3Endpoint string
|
||||
S3Region string
|
||||
S3Bucket string
|
||||
S3AccessKeyID string
|
||||
S3SecretAccessKey string
|
||||
S3PublicEndpoint string // presigned-URL host when it differs from Endpoint
|
||||
S3Prefix string
|
||||
S3PathStyle bool
|
||||
|
||||
// Functions runtime: driver "exec" (runtime binary on the backend host)
|
||||
// or "docker" (ephemeral locked-down container), binary name/path for
|
||||
// exec ("auto" resolves per-function runtime from PATH), timeout and
|
||||
// output cap.
|
||||
FunctionsDriver string
|
||||
FunctionsRuntime string
|
||||
FunctionsTimeout time.Duration
|
||||
FunctionsMaxBytes int64
|
||||
}
|
||||
|
||||
// devEncryptionKey is the built-in credential key for local development only.
|
||||
@@ -63,6 +84,17 @@ func Load() (Config, error) {
|
||||
SMTPUser: os.Getenv("SMTP_USER"),
|
||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
|
||||
StorageDriver: getenv("BACKEND_STORAGE_DRIVER", "local"),
|
||||
S3Endpoint: os.Getenv("S3_ENDPOINT"),
|
||||
S3Region: getenv("S3_REGION", "us-east-1"),
|
||||
S3Bucket: os.Getenv("S3_BUCKET"),
|
||||
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
|
||||
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
||||
S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"),
|
||||
S3Prefix: os.Getenv("S3_PREFIX"),
|
||||
FunctionsDriver: getenv("FUNCTIONS_DRIVER", "exec"),
|
||||
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
|
||||
FunctionsMaxBytes: 65536,
|
||||
}
|
||||
|
||||
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
||||
@@ -95,6 +127,24 @@ func Load() (Config, error) {
|
||||
}
|
||||
cfg.SMTPSecure = smtpSecure
|
||||
|
||||
s3PathStyle, err := strconv.ParseBool(getenv("S3_PATH_STYLE", "true"))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("parse S3_PATH_STYLE: %w", err)
|
||||
}
|
||||
cfg.S3PathStyle = s3PathStyle
|
||||
|
||||
fnTimeout, err := strconv.Atoi(getenv("FUNCTIONS_TIMEOUT_SECONDS", "30"))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("parse FUNCTIONS_TIMEOUT_SECONDS: %w", err)
|
||||
}
|
||||
cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second
|
||||
|
||||
switch cfg.FunctionsDriver {
|
||||
case "exec", "docker":
|
||||
default:
|
||||
return Config{}, fmt.Errorf("FUNCTIONS_DRIVER %q must be exec or docker", cfg.FunctionsDriver)
|
||||
}
|
||||
|
||||
var missing []string
|
||||
if cfg.DatabaseURL == "" {
|
||||
missing = append(missing, "DATABASE_URL")
|
||||
@@ -102,8 +152,23 @@ func Load() (Config, error) {
|
||||
if cfg.JWTSecret == "" {
|
||||
missing = append(missing, "JWT_SECRET")
|
||||
}
|
||||
if cfg.StorageRoot == "" {
|
||||
missing = append(missing, "BACKEND_STORAGE_ROOT")
|
||||
switch cfg.StorageDriver {
|
||||
case "local":
|
||||
if cfg.StorageRoot == "" {
|
||||
missing = append(missing, "BACKEND_STORAGE_ROOT")
|
||||
}
|
||||
case "s3":
|
||||
if cfg.S3Bucket == "" {
|
||||
missing = append(missing, "S3_BUCKET")
|
||||
}
|
||||
if cfg.S3AccessKeyID == "" {
|
||||
missing = append(missing, "S3_ACCESS_KEY_ID")
|
||||
}
|
||||
if cfg.S3SecretAccessKey == "" {
|
||||
missing = append(missing, "S3_SECRET_ACCESS_KEY")
|
||||
}
|
||||
default:
|
||||
return Config{}, fmt.Errorf("unknown BACKEND_STORAGE_DRIVER %q (want local or s3)", cfg.StorageDriver)
|
||||
}
|
||||
if cfg.AuthInternalBaseURL == "" {
|
||||
missing = append(missing, "AUTH_INTERNAL_BASE_URL")
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: alert_rules.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createAlertRule = `-- name: CreateAlertRule :one
|
||||
INSERT INTO core.alert_rules (project_id, name, kind, config)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateAlertRuleParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Config []byte `json:"config"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) {
|
||||
row := q.db.QueryRow(ctx, createAlertRule,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.Kind,
|
||||
arg.Config,
|
||||
)
|
||||
var i CoreAlertRule
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Config,
|
||||
&i.Enabled,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteAlertRule = `-- name: DeleteAlertRule :exec
|
||||
DELETE FROM core.alert_rules
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type DeleteAlertRuleParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteAlertRule, arg.ID, arg.ProjectID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAlertRule = `-- name: GetAlertRule :one
|
||||
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type GetAlertRuleParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) {
|
||||
row := q.db.QueryRow(ctx, getAlertRule, arg.ID, arg.ProjectID)
|
||||
var i CoreAlertRule
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Config,
|
||||
&i.Enabled,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const lastHeartbeats = `-- name: LastHeartbeats :many
|
||||
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
|
||||
FROM core.components co
|
||||
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
|
||||
WHERE co.project_id = $1
|
||||
GROUP BY co.name
|
||||
`
|
||||
|
||||
type LastHeartbeatsRow struct {
|
||||
ComponentName string `json:"component_name"`
|
||||
LastBeat pgtype.Timestamptz `json:"last_beat"`
|
||||
}
|
||||
|
||||
// Per-component most recent heartbeat — heartbeat_silence detection.
|
||||
func (q *Queries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) {
|
||||
rows, err := q.db.Query(ctx, lastHeartbeats, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []LastHeartbeatsRow
|
||||
for rows.Next() {
|
||||
var i LastHeartbeatsRow
|
||||
if err := rows.Scan(&i.ComponentName, &i.LastBeat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAlertRules = `-- name: ListAlertRules :many
|
||||
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||
WHERE project_id = $1
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) {
|
||||
rows, err := q.db.Query(ctx, listAlertRules, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreAlertRule
|
||||
for rows.Next() {
|
||||
var i CoreAlertRule
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Config,
|
||||
&i.Enabled,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listEnabledAlertRules = `-- name: ListEnabledAlertRules :many
|
||||
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||
WHERE enabled
|
||||
`
|
||||
|
||||
func (q *Queries) ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) {
|
||||
rows, err := q.db.Query(ctx, listEnabledAlertRules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreAlertRule
|
||||
for rows.Next() {
|
||||
var i CoreAlertRule
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Config,
|
||||
&i.Enabled,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const recentErrorCounts = `-- name: RecentErrorCounts :many
|
||||
SELECT co.name AS component_name, count(*) AS errors
|
||||
FROM core.events e
|
||||
JOIN core.components co ON co.id = e.component_id
|
||||
WHERE e.project_id = $1 AND e.type = 'error'
|
||||
AND e.ts > now() - make_interval(secs => $2::bigint)
|
||||
GROUP BY co.name
|
||||
`
|
||||
|
||||
type RecentErrorCountsParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Column2 int64 `json:"column_2"`
|
||||
}
|
||||
|
||||
type RecentErrorCountsRow struct {
|
||||
ComponentName string `json:"component_name"`
|
||||
Errors int64 `json:"errors"`
|
||||
}
|
||||
|
||||
// Per-component error count inside a sliding window — error_spike detection.
|
||||
func (q *Queries) RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) {
|
||||
rows, err := q.db.Query(ctx, recentErrorCounts, arg.ProjectID, arg.Column2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []RecentErrorCountsRow
|
||||
for rows.Next() {
|
||||
var i RecentErrorCountsRow
|
||||
if err := rows.Scan(&i.ComponentName, &i.Errors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setAlertRuleState = `-- name: SetAlertRuleState :exec
|
||||
UPDATE core.alert_rules
|
||||
SET state = $3, updated_at = NOW()
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type SetAlertRuleStateParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
State []byte `json:"state"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error {
|
||||
_, err := q.db.Exec(ctx, setAlertRuleState, arg.ID, arg.ProjectID, arg.State)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateAlertRule = `-- name: UpdateAlertRule :one
|
||||
UPDATE core.alert_rules
|
||||
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
|
||||
WHERE id = $1 AND project_id = $2
|
||||
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateAlertRuleParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Config []byte `json:"config"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) {
|
||||
row := q.db.QueryRow(ctx, updateAlertRule,
|
||||
arg.ID,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.Config,
|
||||
arg.Enabled,
|
||||
)
|
||||
var i CoreAlertRule
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Config,
|
||||
&i.Enabled,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: email_log.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const insertEmailLog = `-- name: InsertEmailLog :one
|
||||
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, project_id, template, to_email, subject, status, error, created_at
|
||||
`
|
||||
|
||||
type InsertEmailLogParams struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Template string `json:"template"`
|
||||
ToEmail string `json:"to_email"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) {
|
||||
row := q.db.QueryRow(ctx, insertEmailLog,
|
||||
arg.ProjectID,
|
||||
arg.Template,
|
||||
arg.ToEmail,
|
||||
arg.Subject,
|
||||
arg.Status,
|
||||
arg.Error,
|
||||
)
|
||||
var i CoreEmailLog
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Template,
|
||||
&i.ToEmail,
|
||||
&i.Subject,
|
||||
&i.Status,
|
||||
&i.Error,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listEmailLog = `-- name: ListEmailLog :many
|
||||
SELECT id, project_id, template, to_email, subject, status, error, created_at FROM core.email_log
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListEmailLogParams struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) {
|
||||
rows, err := q.db.Query(ctx, listEmailLog, arg.ProjectID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreEmailLog
|
||||
for rows.Next() {
|
||||
var i CoreEmailLog
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Template,
|
||||
&i.ToEmail,
|
||||
&i.Subject,
|
||||
&i.Status,
|
||||
&i.Error,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: functions.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createFunction = `-- name: CreateFunction :one
|
||||
INSERT INTO core.functions (project_id, name, code, runtime, event_pattern)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
|
||||
`
|
||||
|
||||
type CreateFunctionParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Runtime string `json:"runtime"`
|
||||
EventPattern string `json:"event_pattern"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error) {
|
||||
row := q.db.QueryRow(ctx, createFunction,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.Code,
|
||||
arg.Runtime,
|
||||
arg.EventPattern,
|
||||
)
|
||||
var i CoreFunction
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.Runtime,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EventPattern,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteFunction = `-- name: DeleteFunction :exec
|
||||
DELETE FROM core.functions WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type DeleteFunctionParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteFunction, arg.ID, arg.ProjectID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getFunction = `-- name: GetFunction :one
|
||||
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error) {
|
||||
row := q.db.QueryRow(ctx, getFunction, id)
|
||||
var i CoreFunction
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.Runtime,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EventPattern,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getFunctionByName = `-- name: GetFunctionByName :one
|
||||
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE project_id = $1 AND name = $2
|
||||
`
|
||||
|
||||
type GetFunctionByNameParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error) {
|
||||
row := q.db.QueryRow(ctx, getFunctionByName, arg.ProjectID, arg.Name)
|
||||
var i CoreFunction
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.Runtime,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EventPattern,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertFunctionRun = `-- name: InsertFunctionRun :one
|
||||
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at
|
||||
`
|
||||
|
||||
type InsertFunctionRunParams struct {
|
||||
FunctionID uuid.UUID `json:"function_id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
DurationMs int32 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error) {
|
||||
row := q.db.QueryRow(ctx, insertFunctionRun,
|
||||
arg.FunctionID,
|
||||
arg.Trigger,
|
||||
arg.Status,
|
||||
arg.ExitCode,
|
||||
arg.Stdout,
|
||||
arg.Stderr,
|
||||
arg.DurationMs,
|
||||
)
|
||||
var i CoreFunctionRun
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.FunctionID,
|
||||
&i.Trigger,
|
||||
&i.Status,
|
||||
&i.ExitCode,
|
||||
&i.Stdout,
|
||||
&i.Stderr,
|
||||
&i.DurationMs,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listFunctionRuns = `-- name: ListFunctionRuns :many
|
||||
SELECT id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at FROM core.function_runs
|
||||
WHERE function_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListFunctionRunsParams struct {
|
||||
FunctionID uuid.UUID `json:"function_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error) {
|
||||
rows, err := q.db.Query(ctx, listFunctionRuns, arg.FunctionID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreFunctionRun
|
||||
for rows.Next() {
|
||||
var i CoreFunctionRun
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.FunctionID,
|
||||
&i.Trigger,
|
||||
&i.Status,
|
||||
&i.ExitCode,
|
||||
&i.Stdout,
|
||||
&i.Stderr,
|
||||
&i.DurationMs,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listFunctions = `-- name: ListFunctions :many
|
||||
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
|
||||
WHERE project_id = $1
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error) {
|
||||
rows, err := q.db.Query(ctx, listFunctions, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreFunction
|
||||
for rows.Next() {
|
||||
var i CoreFunction
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.Runtime,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EventPattern,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listFunctionsForEvent = `-- name: ListFunctionsForEvent :many
|
||||
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
|
||||
WHERE project_id = $1
|
||||
AND enabled = TRUE
|
||||
AND event_pattern <> ''
|
||||
AND ($2 LIKE REPLACE(event_pattern, '*', '%') OR event_pattern = '*')
|
||||
`
|
||||
|
||||
type ListFunctionsForEventParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
EventPattern string `json:"event_pattern"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error) {
|
||||
rows, err := q.db.Query(ctx, listFunctionsForEvent, arg.ProjectID, arg.EventPattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreFunction
|
||||
for rows.Next() {
|
||||
var i CoreFunction
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.Runtime,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EventPattern,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateFunction = `-- name: UpdateFunction :one
|
||||
UPDATE core.functions
|
||||
SET code = COALESCE($3, code),
|
||||
enabled = COALESCE($4, enabled),
|
||||
event_pattern = COALESCE($5, event_pattern),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND project_id = $2
|
||||
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
|
||||
`
|
||||
|
||||
type UpdateFunctionParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Code *string `json:"code"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
EventPattern *string `json:"event_pattern"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error) {
|
||||
row := q.db.QueryRow(ctx, updateFunction,
|
||||
arg.ID,
|
||||
arg.ProjectID,
|
||||
arg.Code,
|
||||
arg.Enabled,
|
||||
arg.EventPattern,
|
||||
)
|
||||
var i CoreFunction
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.Runtime,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EventPattern,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: inbound_hooks.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createInboundHook = `-- name: CreateInboundHook :one
|
||||
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, function_id, secret, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id
|
||||
`
|
||||
|
||||
type CreateInboundHookParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
FunctionID pgtype.UUID `json:"function_id"`
|
||||
Secret []byte `json:"secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error) {
|
||||
row := q.db.QueryRow(ctx, createInboundHook,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.Token,
|
||||
arg.Mode,
|
||||
arg.JobID,
|
||||
arg.FunctionID,
|
||||
arg.Secret,
|
||||
arg.Enabled,
|
||||
)
|
||||
var i CoreInboundHook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Mode,
|
||||
&i.JobID,
|
||||
&i.Secret,
|
||||
&i.Enabled,
|
||||
&i.LastReceivedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteInboundHook = `-- name: DeleteInboundHook :exec
|
||||
DELETE FROM core.inbound_hooks
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type DeleteInboundHookParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteInboundHook, arg.ID, arg.ProjectID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getInboundHook = `-- name: GetInboundHook :one
|
||||
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type GetInboundHookParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error) {
|
||||
row := q.db.QueryRow(ctx, getInboundHook, arg.ID, arg.ProjectID)
|
||||
var i CoreInboundHook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Mode,
|
||||
&i.JobID,
|
||||
&i.Secret,
|
||||
&i.Enabled,
|
||||
&i.LastReceivedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getInboundHookByToken = `-- name: GetInboundHookByToken :one
|
||||
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||
WHERE token = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error) {
|
||||
row := q.db.QueryRow(ctx, getInboundHookByToken, token)
|
||||
var i CoreInboundHook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Mode,
|
||||
&i.JobID,
|
||||
&i.Secret,
|
||||
&i.Enabled,
|
||||
&i.LastReceivedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listInboundHooks = `-- name: ListInboundHooks :many
|
||||
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||
WHERE project_id = $1
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error) {
|
||||
rows, err := q.db.Query(ctx, listInboundHooks, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreInboundHook
|
||||
for rows.Next() {
|
||||
var i CoreInboundHook
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Mode,
|
||||
&i.JobID,
|
||||
&i.Secret,
|
||||
&i.Enabled,
|
||||
&i.LastReceivedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const touchInboundHook = `-- name: TouchInboundHook :exec
|
||||
UPDATE core.inbound_hooks
|
||||
SET last_received_at = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) TouchInboundHook(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, touchInboundHook, id)
|
||||
return err
|
||||
}
|
||||
@@ -22,9 +22,10 @@ INSERT INTO core.scheduled_jobs (
|
||||
payload,
|
||||
enabled,
|
||||
next_run_at,
|
||||
created_by_user_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
|
||||
created_by_user_id,
|
||||
function_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
|
||||
`
|
||||
|
||||
type CreateScheduledJobParams struct {
|
||||
@@ -37,6 +38,7 @@ type CreateScheduledJobParams struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
|
||||
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
||||
FunctionID pgtype.UUID `json:"function_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) {
|
||||
@@ -50,6 +52,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob
|
||||
arg.Enabled,
|
||||
arg.NextRunAt,
|
||||
arg.CreatedByUserID,
|
||||
arg.FunctionID,
|
||||
)
|
||||
var i CoreScheduledJob
|
||||
err := row.Scan(
|
||||
@@ -67,6 +70,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob
|
||||
&i.CreatedByUserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -133,7 +137,7 @@ func (q *Queries) FinishScheduledJobRun(ctx context.Context, arg FinishScheduled
|
||||
}
|
||||
|
||||
const getScheduledJobByID = `-- name: GetScheduledJobByID :one
|
||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
|
||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -155,6 +159,7 @@ func (q *Queries) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreSc
|
||||
&i.CreatedByUserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -188,7 +193,7 @@ func (q *Queries) InsertScheduledJobRun(ctx context.Context, arg InsertScheduled
|
||||
}
|
||||
|
||||
const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many
|
||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
|
||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
|
||||
WHERE enabled
|
||||
AND next_run_at IS NOT NULL
|
||||
AND next_run_at <= $1
|
||||
@@ -218,6 +223,7 @@ func (q *Queries) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Tim
|
||||
&i.CreatedByUserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -272,7 +278,7 @@ func (q *Queries) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJob
|
||||
}
|
||||
|
||||
const listScheduledJobs = `-- name: ListScheduledJobs :many
|
||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
|
||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -301,6 +307,7 @@ func (q *Queries) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([
|
||||
&i.CreatedByUserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -319,7 +326,7 @@ SET last_run_at = $2,
|
||||
next_run_at = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
|
||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
|
||||
`
|
||||
|
||||
type MarkScheduledJobRanParams struct {
|
||||
@@ -352,6 +359,7 @@ func (q *Queries) MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobR
|
||||
&i.CreatedByUserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -386,24 +394,27 @@ SET name = $3,
|
||||
next_run_at = $7,
|
||||
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
|
||||
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
|
||||
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND project_id = $2
|
||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
|
||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
|
||||
`
|
||||
|
||||
type UpdateScheduledJobParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Schedule string `json:"schedule"`
|
||||
Url string `json:"url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
|
||||
Column8 bool `json:"column_8"`
|
||||
Secret []byte `json:"secret"`
|
||||
Column10 bool `json:"column_10"`
|
||||
Payload []byte `json:"payload"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Schedule string `json:"schedule"`
|
||||
Url string `json:"url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
|
||||
Column8 bool `json:"column_8"`
|
||||
Secret []byte `json:"secret"`
|
||||
Column10 bool `json:"column_10"`
|
||||
Payload []byte `json:"payload"`
|
||||
Column12 bool `json:"column_12"`
|
||||
FunctionID pgtype.UUID `json:"function_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) {
|
||||
@@ -419,6 +430,8 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob
|
||||
arg.Secret,
|
||||
arg.Column10,
|
||||
arg.Payload,
|
||||
arg.Column12,
|
||||
arg.FunctionID,
|
||||
)
|
||||
var i CoreScheduledJob
|
||||
err := row.Scan(
|
||||
@@ -436,6 +449,7 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob
|
||||
&i.CreatedByUserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -140,6 +140,18 @@ func (ns NullCoreProjectRole) Value() (driver.Value, error) {
|
||||
return string(ns.CoreProjectRole), nil
|
||||
}
|
||||
|
||||
type CoreAlertRule struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Config []byte `json:"config"`
|
||||
Enabled bool `json:"enabled"`
|
||||
State []byte `json:"state"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreApiKey struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
@@ -231,6 +243,17 @@ type CoreDocument struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreEmailLog struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Template string `json:"template"`
|
||||
ToEmail string `json:"to_email"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CoreEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
@@ -243,6 +266,45 @@ type CoreEvent struct {
|
||||
Ts pgtype.Timestamptz `json:"ts"`
|
||||
}
|
||||
|
||||
type CoreFunction struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Runtime string `json:"runtime"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
EventPattern string `json:"event_pattern"`
|
||||
}
|
||||
|
||||
type CoreFunctionRun struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
FunctionID uuid.UUID `json:"function_id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
DurationMs int32 `json:"duration_ms"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CoreInboundHook struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
Secret []byte `json:"secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastReceivedAt pgtype.Timestamptz `json:"last_received_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
FunctionID pgtype.UUID `json:"function_id"`
|
||||
}
|
||||
|
||||
type CoreIntegration struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
@@ -333,6 +395,7 @@ type CoreScheduledJob struct {
|
||||
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
FunctionID pgtype.UUID `json:"function_id"`
|
||||
}
|
||||
|
||||
type CoreScheduledJobRun struct {
|
||||
|
||||
@@ -241,6 +241,23 @@ func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOrganizationByID = `-- name: GetOrganizationByID :one
|
||||
SELECT id, slug, name, created_at FROM core.organizations
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) {
|
||||
row := q.db.QueryRow(ctx, getOrganizationByID, id)
|
||||
var i CoreOrganization
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Slug,
|
||||
&i.Name,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
|
||||
SELECT
|
||||
om.id, om.organization_id, om.user_id, om.role, om.created_at,
|
||||
|
||||
@@ -23,24 +23,30 @@ type Querier interface {
|
||||
CountOrganizations(ctx context.Context) (int64, error)
|
||||
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
|
||||
CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error)
|
||||
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
|
||||
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
|
||||
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
|
||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
||||
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
||||
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
||||
CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error)
|
||||
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
|
||||
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
||||
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
||||
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
|
||||
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
|
||||
CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error)
|
||||
CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error)
|
||||
DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error
|
||||
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
|
||||
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
||||
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
||||
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
||||
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
||||
DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error
|
||||
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
|
||||
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
||||
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||
@@ -54,6 +60,7 @@ type Querier interface {
|
||||
FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
|
||||
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
|
||||
GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error)
|
||||
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
|
||||
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
|
||||
@@ -62,9 +69,14 @@ type Querier interface {
|
||||
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
||||
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
||||
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
||||
GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error)
|
||||
GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error)
|
||||
GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error)
|
||||
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error)
|
||||
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
|
||||
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
||||
GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
|
||||
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
||||
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
|
||||
@@ -77,10 +89,15 @@ type Querier interface {
|
||||
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
|
||||
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
|
||||
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
|
||||
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error)
|
||||
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
||||
InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error)
|
||||
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
|
||||
// Per-component most recent heartbeat — heartbeat_silence detection.
|
||||
LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error)
|
||||
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
|
||||
ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error)
|
||||
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
||||
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
||||
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
|
||||
@@ -90,8 +107,14 @@ type Querier interface {
|
||||
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
|
||||
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
||||
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
|
||||
ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error)
|
||||
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
||||
ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error)
|
||||
ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error)
|
||||
ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error)
|
||||
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
|
||||
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
||||
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
||||
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
||||
@@ -114,10 +137,13 @@ type Querier interface {
|
||||
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
|
||||
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
||||
PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error
|
||||
// Per-component error count inside a sliding window — error_spike detection.
|
||||
RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error)
|
||||
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
|
||||
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
||||
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
|
||||
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
|
||||
SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error
|
||||
SweepExpiredAuditLogs(ctx context.Context) (int64, error)
|
||||
// Per-project retention sweeps. A retention of 0 days disables the sweep for
|
||||
// that stream. Pending webhook deliveries are never swept — they may still be
|
||||
@@ -128,9 +154,12 @@ type Querier interface {
|
||||
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
|
||||
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
|
||||
TouchAPIKey(ctx context.Context, id uuid.UUID) error
|
||||
TouchInboundHook(ctx context.Context, id uuid.UUID) error
|
||||
UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error)
|
||||
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
||||
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
||||
UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error)
|
||||
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
|
||||
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
|
||||
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
||||
|
||||
@@ -291,7 +291,10 @@ const listWebhooksForEvent = `-- name: ListWebhooksForEvent :many
|
||||
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
|
||||
WHERE project_id = $1
|
||||
AND enabled
|
||||
AND (cardinality(events) = 0 OR $2::text = ANY(events))
|
||||
AND (cardinality(events) = 0 OR EXISTS (
|
||||
SELECT 1 FROM unnest(events) AS e
|
||||
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
|
||||
))
|
||||
`
|
||||
|
||||
type ListWebhooksForEventParams struct {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||
@@ -35,6 +37,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
api.GET("/me", h.me)
|
||||
api.GET("/context", h.actorContext)
|
||||
api.GET("/instance/public", h.instancePublic)
|
||||
api.GET("/instance/settings", h.listInstanceSettings)
|
||||
api.PUT("/instance/settings/:key", h.updateInstanceSetting)
|
||||
@@ -79,7 +82,20 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
||||
api.OPTIONS("/ingest", h.ingestOptions)
|
||||
api.POST("/ingest", h.ingest)
|
||||
api.POST("/hooks/:token", h.receiveInboundHook)
|
||||
api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks)
|
||||
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
|
||||
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
|
||||
api.GET("/projects/:projectID/emails", h.listEmailLog)
|
||||
api.GET("/projects/:projectID/functions", h.listFunctions)
|
||||
api.POST("/projects/:projectID/functions", h.createFunction)
|
||||
api.GET("/projects/:projectID/functions/:functionID", h.getFunction)
|
||||
api.PUT("/projects/:projectID/functions/:functionID", h.updateFunction)
|
||||
api.DELETE("/projects/:projectID/functions/:functionID", h.deleteFunction)
|
||||
api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction)
|
||||
api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns)
|
||||
api.GET("/projects/:projectID/events", h.listEvents)
|
||||
api.POST("/projects/:projectID/events", h.publishEvent)
|
||||
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||
api.GET("/projects/:projectID/components", h.listComponents)
|
||||
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
|
||||
@@ -121,10 +137,17 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret)
|
||||
api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret)
|
||||
api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret)
|
||||
|
||||
api.GET("/projects/:projectID/alerts", h.listAlertRules)
|
||||
api.POST("/projects/:projectID/alerts", h.createAlertRule)
|
||||
api.PUT("/projects/:projectID/alerts/:ruleID", h.updateAlertRule)
|
||||
api.DELETE("/projects/:projectID/alerts/:ruleID", h.deleteAlertRule)
|
||||
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
|
||||
api.GET("/projects/:projectID/realtime/presence", h.realtimePresence)
|
||||
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
||||
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
||||
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
|
||||
api.POST("/buckets/:bucketID/object-presigns", h.presignObject)
|
||||
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
|
||||
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
|
||||
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
|
||||
@@ -161,6 +184,19 @@ func (h *HTTPHandler) me(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) actorContext(c *gin.Context) {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.ActorContext(c.Request.Context(), actor)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) bootstrap(c *gin.Context) {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
@@ -874,6 +910,43 @@ func (h *HTTPHandler) updateObject(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type presignObjectRequest struct {
|
||||
Key string `json:"key" validate:"required"`
|
||||
Op string `json:"op" validate:"omitempty,oneof=download upload"`
|
||||
TTLSeconds int `json:"ttl_seconds" validate:"omitempty,min=60,max=3600"`
|
||||
}
|
||||
|
||||
// presignObject returns a short-lived URL clients call against storage
|
||||
// directly — keeps large transfers off the backend. S3 driver only.
|
||||
func (h *HTTPHandler) presignObject(c *gin.Context) {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
bucketID, ok := parseUUIDParam(c, "bucketID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body presignObjectRequest
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
out, err := h.Platform.PresignObject(c.Request.Context(), actor, bucketID, strings.TrimSpace(body.Key), body.Op, body.TTLSeconds)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrPresignUnsupported) {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "presign_unsupported", err.Error())
|
||||
return
|
||||
}
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteObject(c *gin.Context) {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
@@ -1207,17 +1280,58 @@ func (h *HTTPHandler) bindAndValidate(c *gin.Context, target any) bool {
|
||||
return false
|
||||
}
|
||||
if err := h.Validate.Struct(target); err != nil {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", err.Error())
|
||||
var valErrs validator.ValidationErrors
|
||||
if errors.As(err, &valErrs) && len(valErrs) > 0 {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", formatFieldError(valErrs[0]))
|
||||
} else {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", "invalid request body")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func formatFieldError(f validator.FieldError) string {
|
||||
if f.Tag() == "required" {
|
||||
return fmt.Sprintf("%s is required", strings.ToLower(f.Field()))
|
||||
}
|
||||
return fmt.Sprintf("%s failed %s validation", strings.ToLower(f.Field()), f.Tag())
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) handleError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, io.EOF):
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
apperrors.Abort(c, http.StatusNotFound, "not_found", "resource not found")
|
||||
default:
|
||||
var inputErr *services.InputError
|
||||
if errors.As(err, &inputErr) {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", inputErr.Error())
|
||||
return
|
||||
}
|
||||
var valErrs validator.ValidationErrors
|
||||
if errors.As(err, &valErrs) && len(valErrs) > 0 {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", formatFieldError(valErrs[0]))
|
||||
return
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
// Structured codes — never leak raw SQLSTATE text or constraint names.
|
||||
switch pgErr.Code {
|
||||
case "23505":
|
||||
apperrors.Abort(c, http.StatusConflict, "conflict", "resource already exists")
|
||||
case "23503":
|
||||
apperrors.Abort(c, http.StatusConflict, "conflict", "referenced resource does not exist")
|
||||
case "22001", "54000":
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "value too large")
|
||||
case "22021":
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "input is not valid UTF-8")
|
||||
default:
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "request_failed", "database error")
|
||||
}
|
||||
return
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
message := strings.ToLower(err.Error())
|
||||
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
|
||||
|
||||
@@ -4,9 +4,14 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||
)
|
||||
|
||||
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
@@ -104,4 +109,46 @@ func TestHandleErrorStatusMapping(t *testing.T) {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("maps missing rows to not found", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("")
|
||||
handler.handleError(ctx, pgx.ErrNoRows)
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("maps InputError to bad request with message", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("")
|
||||
handler.handleError(ctx, services.NewInputError("webhook url must be https"))
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "webhook url must be https") {
|
||||
t.Fatalf("expected reason in body, got %s", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("maps unique violation to conflict without leaking", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("")
|
||||
handler.handleError(ctx, &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint \"projects_slug_key\""})
|
||||
if recorder.Code != http.StatusConflict {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "projects_slug_key") || strings.Contains(recorder.Body.String(), "23505") {
|
||||
t.Fatalf("raw constraint leaked: %s", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("maps unknown pg error to internal error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("")
|
||||
handler.handleError(ctx, &pgconn.PgError{Code: "XX000", Message: "internal"})
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||
)
|
||||
|
||||
// Inbound hooks are project-scoped public ingest endpoints:
|
||||
// POST /api/v1/hooks/<token>. A hook either republishes the body as an
|
||||
// `inbound.received` domain event (webhooks + realtime fan out for free) or
|
||||
// triggers a scheduled-job run with the body as the payload. When a secret is
|
||||
// set, callers must sign the raw body with X-Primora-Signature — the same
|
||||
// HMAC-SHA256 scheme outbound deliveries use.
|
||||
|
||||
const (
|
||||
WebhookEventInboundReceived = "inbound.received"
|
||||
|
||||
InboundModeEvent = "event"
|
||||
InboundModeJob = "job"
|
||||
InboundModeFunction = "function"
|
||||
)
|
||||
|
||||
type InboundHookSummary struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
URL string `json:"url"`
|
||||
Mode string `json:"mode"`
|
||||
JobID string `json:"job_id,omitempty"`
|
||||
FunctionID string `json:"function_id,omitempty"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastReceivedAt *time.Time `json:"last_received_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateInboundHookInput struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Mode string `json:"mode"`
|
||||
JobID string `json:"job_id"`
|
||||
FunctionID string `json:"function_id"`
|
||||
Secret string `json:"secret"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type InboundReceiveResult struct {
|
||||
Received bool `json:"received"`
|
||||
Event string `json:"event,omitempty"`
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
}
|
||||
|
||||
func inboundHookSummary(row db.CoreInboundHook, publicURL string) InboundHookSummary {
|
||||
out := InboundHookSummary{
|
||||
ID: row.ID.String(),
|
||||
ProjectID: row.ProjectID.String(),
|
||||
Name: row.Name,
|
||||
Token: row.Token,
|
||||
URL: strings.TrimSuffix(publicURL, "/") + "/api/v1/hooks/" + row.Token,
|
||||
Mode: row.Mode,
|
||||
HasSecret: len(row.Secret) > 0,
|
||||
Enabled: row.Enabled,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
}
|
||||
if row.JobID.Valid {
|
||||
out.JobID = uuid.UUID(row.JobID.Bytes).String()
|
||||
}
|
||||
if row.FunctionID.Valid {
|
||||
out.FunctionID = uuid.UUID(row.FunctionID.Bytes).String()
|
||||
}
|
||||
if row.LastReceivedAt.Valid {
|
||||
t := row.LastReceivedAt.Time
|
||||
out.LastReceivedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListInboundHooks(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]InboundHookSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.repo.Queries().ListInboundHooks(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]InboundHookSummary, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = inboundHookSummary(r, s.publicURL)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) CreateInboundHook(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateInboundHookInput, requestID string) (InboundHookSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return InboundHookSummary{}, err
|
||||
}
|
||||
if !projectSecretNameRe.MatchString(input.Name) {
|
||||
return InboundHookSummary{}, fmt.Errorf("invalid hook name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name)
|
||||
}
|
||||
mode := input.Mode
|
||||
if mode == "" {
|
||||
mode = InboundModeEvent
|
||||
}
|
||||
var jobID, functionID pgtype.UUID
|
||||
switch mode {
|
||||
case InboundModeJob:
|
||||
parsed, err := uuid.Parse(input.JobID)
|
||||
if err != nil {
|
||||
return InboundHookSummary{}, errors.New("job mode requires a valid job_id")
|
||||
}
|
||||
job, err := s.repo.Queries().GetScheduledJobByID(ctx, parsed)
|
||||
if err != nil || job.ProjectID != projectID {
|
||||
return InboundHookSummary{}, errors.New("job not found in this project")
|
||||
}
|
||||
jobID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
case InboundModeFunction:
|
||||
parsed, err := uuid.Parse(input.FunctionID)
|
||||
if err != nil {
|
||||
return InboundHookSummary{}, errors.New("function mode requires a valid function_id")
|
||||
}
|
||||
fn, err := s.repo.Queries().GetFunction(ctx, parsed)
|
||||
if err != nil || fn.ProjectID != projectID {
|
||||
return InboundHookSummary{}, errors.New("function not found in this project")
|
||||
}
|
||||
functionID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
case InboundModeEvent:
|
||||
default:
|
||||
return InboundHookSummary{}, fmt.Errorf("invalid mode %q — event, job, or function", input.Mode)
|
||||
}
|
||||
secretBytes := []byte{}
|
||||
if input.Secret != "" {
|
||||
if s.enc == nil {
|
||||
return InboundHookSummary{}, errors.New("encryption is not configured — cannot store hook secrets")
|
||||
}
|
||||
var err error
|
||||
if secretBytes, err = s.enc.Encrypt([]byte(input.Secret)); err != nil {
|
||||
return InboundHookSummary{}, err
|
||||
}
|
||||
}
|
||||
token := "prm_hook_" + randHex(16)
|
||||
enabled := true
|
||||
if input.Enabled != nil {
|
||||
enabled = *input.Enabled
|
||||
}
|
||||
row, err := s.repo.Queries().CreateInboundHook(ctx, db.CreateInboundHookParams{
|
||||
ProjectID: projectID,
|
||||
Name: input.Name,
|
||||
Token: token,
|
||||
Mode: mode,
|
||||
JobID: jobID,
|
||||
FunctionID: functionID,
|
||||
Secret: secretBytes,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "inbound_hooks_project_id_name_key") {
|
||||
return InboundHookSummary{}, fmt.Errorf("an inbound hook named %s already exists", input.Name)
|
||||
}
|
||||
return InboundHookSummary{}, err
|
||||
}
|
||||
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||
if err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.created", "inbound_hook", row.ID.String(), map[string]any{
|
||||
"name": row.Name, "mode": row.Mode,
|
||||
}))
|
||||
}
|
||||
return inboundHookSummary(row, s.publicURL), nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) DeleteInboundHook(ctx context.Context, actor *models.Actor, projectID, hookID uuid.UUID, requestID string) error {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.repo.Queries().GetInboundHook(ctx, db.GetInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.Queries().DeleteInboundHook(ctx, db.DeleteInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||
if err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.deleted", "inbound_hook", hookID.String(), nil))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReceiveInboundHook handles POST /api/v1/hooks/:token — unauthenticated; the
|
||||
// token is the credential. Signature check applies when a secret is set.
|
||||
func (s *PlatformService) ReceiveInboundHook(ctx context.Context, token string, signature string, body []byte) (InboundReceiveResult, error) {
|
||||
hook, err := s.repo.Queries().GetInboundHookByToken(ctx, token)
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err // pgx.ErrNoRows → 404
|
||||
}
|
||||
if !hook.Enabled {
|
||||
return InboundReceiveResult{}, errors.New("hook is disabled")
|
||||
}
|
||||
if len(hook.Secret) > 0 {
|
||||
if s.enc == nil {
|
||||
return InboundReceiveResult{}, errors.New("encryption is not configured")
|
||||
}
|
||||
plain, err := s.enc.Decrypt(hook.Secret)
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
want := "sha256=" + signBody(string(plain), body)
|
||||
if subtle.ConstantTimeCompare([]byte(signature), []byte(want)) != 1 {
|
||||
return InboundReceiveResult{}, errors.New("invalid signature")
|
||||
}
|
||||
}
|
||||
var payload map[string]any
|
||||
if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return InboundReceiveResult{}, fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
}
|
||||
_ = s.repo.Queries().TouchInboundHook(ctx, hook.ID)
|
||||
|
||||
switch hook.Mode {
|
||||
case InboundModeJob:
|
||||
if s.scheduler == nil {
|
||||
return InboundReceiveResult{}, errors.New("scheduler unavailable")
|
||||
}
|
||||
if !hook.JobID.Valid {
|
||||
return InboundReceiveResult{}, errors.New("hook has no job configured")
|
||||
}
|
||||
job, err := s.repo.Queries().GetScheduledJobByID(ctx, hook.JobID.Bytes)
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
if payload != nil {
|
||||
raw, _ := json.Marshal(payload)
|
||||
job.Payload = raw // received body replaces the configured payload
|
||||
}
|
||||
run, err := s.scheduler.enqueue(ctx, job, "hook")
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
return InboundReceiveResult{Received: true, Event: WebhookEventJobRun, RunID: run.ID.String()}, nil
|
||||
case InboundModeFunction:
|
||||
if s.functions == nil {
|
||||
return InboundReceiveResult{}, errors.New("functions runtime not configured")
|
||||
}
|
||||
if !hook.FunctionID.Valid {
|
||||
return InboundReceiveResult{}, errors.New("hook has no function configured")
|
||||
}
|
||||
fn, err := s.repo.Queries().GetFunction(ctx, uuid.UUID(hook.FunctionID.Bytes))
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
raw := body
|
||||
if len(raw) == 0 {
|
||||
raw = []byte(`{}`)
|
||||
}
|
||||
// Detached: the public endpoint acks immediately; the run row records
|
||||
// the outcome. Hook callers must not wait on user code.
|
||||
go func() {
|
||||
runCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := s.executeFunction(runCtx, fn, raw, "hook"); err != nil && s.logger != nil {
|
||||
s.logger.Warn("hook function invoke failed", "hook", hook.Name, "function", fn.Name, "error", err)
|
||||
}
|
||||
}()
|
||||
return InboundReceiveResult{Received: true, Event: "function.invoked"}, nil
|
||||
default:
|
||||
s.publishEvent(ctx, hook.ProjectID, WebhookEventInboundReceived, map[string]any{
|
||||
"hook_id": hook.ID.String(),
|
||||
"hook_name": hook.Name,
|
||||
"payload": payload,
|
||||
})
|
||||
return InboundReceiveResult{Received: true, Event: WebhookEventInboundReceived}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -104,4 +104,39 @@ func TestValidateWebhookEvents(t *testing.T) {
|
||||
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
|
||||
t.Error("expected unknown event rejected")
|
||||
}
|
||||
for _, ok := range [][]string{
|
||||
{"*"},
|
||||
{"document.*"},
|
||||
{"custom.deploy.done"},
|
||||
{"custom.*"},
|
||||
{"custom.deploy.*"},
|
||||
} {
|
||||
if _, err := validateWebhookEvents(ok); err != nil {
|
||||
t.Errorf("expected %v accepted, got %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range [][]string{
|
||||
{"banana.*"}, {"custom..*"}, {"custom.UPPER"}, {"document.exploded"},
|
||||
} {
|
||||
if _, err := validateWebhookEvents(bad); err == nil {
|
||||
t.Errorf("expected %v rejected", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateWebhookHostHardening(t *testing.T) {
|
||||
private := []string{"localhost", "minio", "echo-svc", "host.docker.internal",
|
||||
"10.0.0.4", "172.16.5.5", "192.168.1.10", "fe80::1", "svc.local", "db.internal"}
|
||||
public := []string{"169.254.169.254", "fd00:ec2::254", "2130706433", "0x7f000001",
|
||||
"8.8.8.8", "example.com", "999999999999"}
|
||||
for _, h := range private {
|
||||
if !isPrivateWebhookHost(h) {
|
||||
t.Errorf("expected %q private", h)
|
||||
}
|
||||
}
|
||||
for _, h := range public {
|
||||
if isPrivateWebhookHost(h) {
|
||||
t.Errorf("expected %q public/blocked", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
@@ -34,6 +35,7 @@ type ScheduledJobSummary struct {
|
||||
Name string `json:"name"`
|
||||
Schedule string `json:"schedule"`
|
||||
URL string `json:"url"`
|
||||
FunctionID *string `json:"function_id,omitempty"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
@@ -58,21 +60,23 @@ type ScheduledJobRunSummary struct {
|
||||
}
|
||||
|
||||
type CreateScheduledJobInput struct {
|
||||
Name string `json:"name" validate:"required,min=2"`
|
||||
Schedule string `json:"schedule" validate:"required"`
|
||||
URL string `json:"url" validate:"required,url"`
|
||||
Secret string `json:"secret"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name" validate:"required,min=2"`
|
||||
Schedule string `json:"schedule" validate:"required"`
|
||||
URL string `json:"url" validate:"required_without=FunctionID,omitempty,url"`
|
||||
FunctionID string `json:"function_id" validate:"omitempty,uuid"`
|
||||
Secret string `json:"secret"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type UpdateScheduledJobInput struct {
|
||||
Name *string `json:"name" validate:"omitempty,min=2"`
|
||||
Schedule *string `json:"schedule"`
|
||||
URL *string `json:"url" validate:"omitempty,url"`
|
||||
Secret *string `json:"secret"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name *string `json:"name" validate:"omitempty,min=2"`
|
||||
Schedule *string `json:"schedule"`
|
||||
URL *string `json:"url" validate:"omitempty,url"`
|
||||
FunctionID *string `json:"function_id" validate:"omitempty,uuid"`
|
||||
Secret *string `json:"secret"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
|
||||
@@ -87,6 +91,10 @@ func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
|
||||
HasSecret: len(row.Secret) > 0,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
}
|
||||
if row.FunctionID.Valid {
|
||||
id := uuid.UUID(row.FunctionID.Bytes).String()
|
||||
out.FunctionID = &id
|
||||
}
|
||||
if row.LastRunAt.Valid {
|
||||
t := row.LastRunAt.Time
|
||||
out.LastRunAt = &t
|
||||
@@ -175,9 +183,25 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return ScheduledJobSummary{}, err
|
||||
}
|
||||
jobURL, err := normalizeWebhookURL(input.URL)
|
||||
if err != nil {
|
||||
return ScheduledJobSummary{}, err
|
||||
jobURL := ""
|
||||
var functionID pgtype.UUID
|
||||
if input.FunctionID != "" {
|
||||
fid, err := uuid.Parse(input.FunctionID)
|
||||
if err != nil {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
|
||||
}
|
||||
fn, err := s.repo.Queries().GetFunction(ctx, fid)
|
||||
if err != nil || fn.ProjectID != projectID {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
|
||||
}
|
||||
functionID = pgtype.UUID{Bytes: fid, Valid: true}
|
||||
jobURL = "function://" + fid.String()
|
||||
} else {
|
||||
var err error
|
||||
jobURL, err = normalizeWebhookURL(input.URL)
|
||||
if err != nil {
|
||||
return ScheduledJobSummary{}, err
|
||||
}
|
||||
}
|
||||
sched, err := parseSchedule(input.Schedule)
|
||||
if err != nil {
|
||||
@@ -221,6 +245,7 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
|
||||
Enabled: enabled,
|
||||
NextRunAt: next,
|
||||
CreatedByUserID: createdBy,
|
||||
FunctionID: functionID,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
||||
@@ -259,6 +284,28 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
|
||||
return ScheduledJobSummary{}, err
|
||||
}
|
||||
}
|
||||
rotateFunction := input.FunctionID != nil
|
||||
functionID := row.FunctionID
|
||||
if rotateFunction {
|
||||
raw := strings.TrimSpace(*input.FunctionID)
|
||||
if raw == "" {
|
||||
functionID = pgtype.UUID{} // explicit clear → back to URL delivery
|
||||
if input.URL == nil && strings.HasPrefix(jobURL, "function://") {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("clearing function_id requires a url")
|
||||
}
|
||||
} else {
|
||||
fid, ferr := uuid.Parse(raw)
|
||||
if ferr != nil {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
|
||||
}
|
||||
fn, ferr := s.repo.Queries().GetFunction(ctx, fid)
|
||||
if ferr != nil || fn.ProjectID != projectID {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
|
||||
}
|
||||
functionID = pgtype.UUID{Bytes: fid, Valid: true}
|
||||
jobURL = "function://" + fid.String()
|
||||
}
|
||||
}
|
||||
enabled := row.Enabled
|
||||
if input.Enabled != nil {
|
||||
enabled = *input.Enabled
|
||||
@@ -301,6 +348,8 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
|
||||
Secret: secretBytes,
|
||||
Column10: rotatePayload,
|
||||
Payload: payload,
|
||||
Column12: rotateFunction,
|
||||
FunctionID: functionID,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
||||
@@ -406,8 +455,13 @@ type JobScheduler struct {
|
||||
quit chan struct{}
|
||||
wg sync.WaitGroup
|
||||
started sync.Once
|
||||
leader *leaderState
|
||||
// onEvent fans a finished run out to webhooks and the realtime stream.
|
||||
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
|
||||
// runFn delivers to a function instead of HTTP when job.FunctionID is set.
|
||||
// The function receives the resolved job payload, not the run envelope.
|
||||
// Owned by PlatformService — the scheduler stays transport-agnostic.
|
||||
runFn func(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error)
|
||||
}
|
||||
|
||||
type jobExec struct {
|
||||
@@ -415,7 +469,7 @@ type jobExec struct {
|
||||
run db.CoreScheduledJobRun
|
||||
}
|
||||
|
||||
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)) *JobScheduler {
|
||||
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), pool *pgxpool.Pool) *JobScheduler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
@@ -428,6 +482,7 @@ func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger,
|
||||
queue: make(chan jobExec, 256),
|
||||
quit: make(chan struct{}),
|
||||
onEvent: onEvent,
|
||||
leader: newLeaderState(pool, leaderKeyScheduler),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,8 +507,13 @@ func (j *JobScheduler) run(ctx context.Context) {
|
||||
case exec := <-j.queue:
|
||||
j.execute(exec.job, exec.run)
|
||||
case <-ticker.C:
|
||||
j.scan()
|
||||
// Only the leader fires scheduled runs — manual/hook enqueues are
|
||||
// already scoped to whichever replica received the request.
|
||||
if j.leader.hold(ctx) {
|
||||
j.scan()
|
||||
}
|
||||
case <-j.quit:
|
||||
j.leader.stop(context.Background())
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
@@ -551,7 +611,8 @@ func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, sta
|
||||
}
|
||||
}
|
||||
|
||||
// deliver POSTs the job payload to its URL, signed like a webhook delivery.
|
||||
// deliver POSTs the job payload to its URL — or invokes the target function
|
||||
// when the job carries a function_id.
|
||||
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
|
||||
data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload)
|
||||
if err != nil {
|
||||
@@ -567,6 +628,12 @@ func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run
|
||||
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"data": data,
|
||||
})
|
||||
if job.FunctionID.Valid {
|
||||
if j.runFn == nil {
|
||||
return nil, fmt.Errorf("functions runtime not configured")
|
||||
}
|
||||
return j.runFn(ctx, job, run, data)
|
||||
}
|
||||
secret := ""
|
||||
if len(job.Secret) > 0 && j.enc != nil {
|
||||
if plain, err := j.enc.Decrypt(job.Secret); err == nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
"github.com/tdvorak/primora/apps/backend/internal/config"
|
||||
)
|
||||
|
||||
// Mailer is transport-only: Resend when configured, plain SMTP otherwise.
|
||||
// Templates render (subject, body) from data; sends are logged to
|
||||
// core.email_log by the service layer so operators can see what went out.
|
||||
type Mailer struct {
|
||||
cfg config.Config
|
||||
resend *resend.Client
|
||||
@@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer {
|
||||
return &Mailer{cfg: cfg, resend: resendClient}
|
||||
}
|
||||
|
||||
func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error {
|
||||
subject := fmt.Sprintf("You were invited to %s on Primora", organizationName)
|
||||
text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n"
|
||||
// mailTemplate renders a subject + plaintext body from template data.
|
||||
type mailTemplate func(data map[string]string) (subject string, body string)
|
||||
|
||||
var mailTemplates = map[string]mailTemplate{
|
||||
"invitation": func(d map[string]string) (string, string) {
|
||||
subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"])
|
||||
body := fmt.Sprintf(
|
||||
"You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n",
|
||||
d["invite_url"],
|
||||
)
|
||||
return subject, body
|
||||
},
|
||||
}
|
||||
|
||||
func renderMailTemplate(template string, data map[string]string) (string, string, error) {
|
||||
tpl, ok := mailTemplates[template]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unknown email template %q", template)
|
||||
}
|
||||
subject, body := tpl(data)
|
||||
return subject, body, nil
|
||||
}
|
||||
|
||||
func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error {
|
||||
if m.resend != nil {
|
||||
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
||||
From: m.cfg.MailFrom,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderMailTemplateInvitation(t *testing.T) {
|
||||
subject, body, err := renderMailTemplate("invitation", map[string]string{
|
||||
"organization": "Acme Corp",
|
||||
"invite_url": "https://app.primora.dev/accept?token=abc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(subject, "Acme Corp") {
|
||||
t.Fatalf("subject missing organization: %q", subject)
|
||||
}
|
||||
if !strings.Contains(body, "https://app.primora.dev/accept?token=abc") {
|
||||
t.Fatalf("body missing invite url: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMailTemplateUnknown(t *testing.T) {
|
||||
if _, _, err := renderMailTemplate("does-not-exist", nil); err == nil {
|
||||
t.Fatal("expected error for unknown template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmail(t *testing.T) {
|
||||
if got := extractEmail("Primora <[email protected]>"); got != "[email protected]" {
|
||||
t.Fatalf("extractEmail = %q", got)
|
||||
}
|
||||
if got := extractEmail("[email protected]"); got != "[email protected]" {
|
||||
t.Fatalf("extractEmail bare = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,12 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -29,7 +32,7 @@ import (
|
||||
|
||||
type PlatformService struct {
|
||||
repo *repositories.CoreRepository
|
||||
store *storage.LocalStore
|
||||
store storage.Store
|
||||
mailer *Mailer
|
||||
publicURL string
|
||||
dbx *dbx.Client
|
||||
@@ -38,6 +41,13 @@ type PlatformService struct {
|
||||
enc *secrets.Encryptor
|
||||
dispatcher *WebhookDispatcher
|
||||
scheduler *JobScheduler
|
||||
alerts *alertEvaluator
|
||||
functions FunctionRunner
|
||||
logger *slog.Logger
|
||||
|
||||
// presenceMu guards presence: realtime subscriber counts per project.
|
||||
presenceMu sync.Mutex
|
||||
presence map[uuid.UUID]int
|
||||
}
|
||||
|
||||
type BootstrapInput struct {
|
||||
@@ -223,17 +233,84 @@ type InvitationSummary struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
|
||||
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc}
|
||||
func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *PlatformService {
|
||||
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions, logger: logger, presence: map[uuid.UUID]int{}}
|
||||
if enc != nil {
|
||||
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
||||
s.dispatcher.Start(context.Background())
|
||||
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent)
|
||||
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent, repo.Pool())
|
||||
s.scheduler.runFn = s.runJobFunction
|
||||
s.scheduler.Start(context.Background())
|
||||
s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool())
|
||||
s.alerts.Start(context.Background())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sendTemplatedEmail renders a registered template, sends it, and writes an
|
||||
// email_log row with the outcome — failed sends are still logged so the
|
||||
// dashboard shows them.
|
||||
func (s *PlatformService) sendTemplatedEmail(ctx context.Context, projectID *uuid.UUID, template, to string, data map[string]string) error {
|
||||
subject, body, err := renderMailTemplate(template, data)
|
||||
status, errText := "sent", ""
|
||||
if err == nil {
|
||||
if sendErr := s.mailer.Send(ctx, to, subject, body); sendErr != nil {
|
||||
status, errText, err = "failed", sendErr.Error(), sendErr
|
||||
}
|
||||
} else {
|
||||
status, errText = "failed", err.Error()
|
||||
}
|
||||
var pid pgtype.UUID
|
||||
if projectID != nil {
|
||||
pid = pgtype.UUID{Bytes: *projectID, Valid: true}
|
||||
}
|
||||
_, _ = s.repo.Queries().InsertEmailLog(ctx, db.InsertEmailLogParams{
|
||||
ProjectID: pid,
|
||||
Template: template,
|
||||
ToEmail: to,
|
||||
Subject: subject,
|
||||
Status: status,
|
||||
Error: errText,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type EmailLogSummary struct {
|
||||
ID string `json:"id"`
|
||||
Template string `json:"template"`
|
||||
ToEmail string `json:"to_email"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListEmailLog(ctx context.Context, actor *models.Actor, projectID uuid.UUID, limit int64) ([]EmailLogSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.repo.Queries().ListEmailLog(ctx, db.ListEmailLogParams{ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, Limit: int32(limit)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]EmailLogSummary, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = EmailLogSummary{
|
||||
ID: r.ID.String(),
|
||||
Template: r.Template,
|
||||
ToEmail: r.ToEmail,
|
||||
Subject: r.Subject,
|
||||
Status: r.Status,
|
||||
Error: r.Error,
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Close stops the webhook dispatcher and job scheduler. Idempotent.
|
||||
func (s *PlatformService) Close() {
|
||||
if s.dispatcher != nil {
|
||||
@@ -242,6 +319,9 @@ func (s *PlatformService) Close() {
|
||||
if s.scheduler != nil {
|
||||
s.scheduler.Stop()
|
||||
}
|
||||
if s.alerts != nil {
|
||||
s.alerts.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// publishEvent fans a domain event out to realtime SSE subscribers and to
|
||||
@@ -256,18 +336,118 @@ func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID,
|
||||
})
|
||||
}
|
||||
s.dispatchEvent(ctx, projectID, eventType, data)
|
||||
s.triggerFunctionsForEvent(ctx, projectID, eventType, data)
|
||||
}
|
||||
|
||||
// SubscribeRealtime registers an SSE consumer for project domain events.
|
||||
// SubscribeRealtime registers an SSE consumer for project domain events and
|
||||
// bumps the project's presence count.
|
||||
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.realtime.Subscribe(projectID.String()), nil
|
||||
ch := s.realtime.Subscribe(projectID.String())
|
||||
s.bumpPresence(projectID, 1)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
|
||||
s.realtime.Unsubscribe(projectID.String(), ch)
|
||||
s.bumpPresence(projectID, -1)
|
||||
}
|
||||
|
||||
// bumpPresence adjusts the online count and broadcasts presence.update to
|
||||
// realtime subscribers only — deliberately not through publishEvent, so
|
||||
// webhooks and `event_pattern: "*"` functions don't fire on connect noise.
|
||||
func (s *PlatformService) bumpPresence(projectID uuid.UUID, delta int) {
|
||||
s.presenceMu.Lock()
|
||||
n := s.presence[projectID] + delta
|
||||
if n <= 0 {
|
||||
delete(s.presence, projectID)
|
||||
n = 0
|
||||
} else {
|
||||
s.presence[projectID] = n
|
||||
}
|
||||
s.presenceMu.Unlock()
|
||||
s.realtime.Broadcast(projectID.String(), map[string]any{
|
||||
"type": "presence.update",
|
||||
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"data": map[string]any{"online": n},
|
||||
})
|
||||
}
|
||||
|
||||
// Presence returns the current realtime subscriber count for a project.
|
||||
func (s *PlatformService) Presence(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (int, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.presenceMu.Lock()
|
||||
defer s.presenceMu.Unlock()
|
||||
return s.presence[projectID], nil
|
||||
}
|
||||
|
||||
var customEventTypeRe = regexp.MustCompile(`^custom\.[a-z0-9][a-z0-9_.-]{0,62}$`)
|
||||
|
||||
// PublishProjectEvent lets a member emit a custom domain event — realtime
|
||||
// subscribers, matching webhooks, and event_pattern functions all fire via
|
||||
// the standard publishEvent fan-out. The custom.* prefix is enforced so
|
||||
// clients can't spoof system event types (document.created etc.).
|
||||
func (s *PlatformService) PublishProjectEvent(ctx context.Context, actor *models.Actor, projectID uuid.UUID, eventType string, data map[string]any) error {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !customEventTypeRe.MatchString(eventType) {
|
||||
return errors.New("invalid event type: must match custom.<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) {
|
||||
@@ -907,7 +1087,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac
|
||||
if err != nil {
|
||||
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
|
||||
}
|
||||
if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil {
|
||||
var inviteProject *uuid.UUID
|
||||
if projectUUID.Valid {
|
||||
id := uuid.UUID(projectUUID.Bytes)
|
||||
inviteProject = &id
|
||||
}
|
||||
if err := s.sendTemplatedEmail(ctx, inviteProject, "invitation", invitation.Email, map[string]string{
|
||||
"organization": "Primora",
|
||||
"invite_url": inviteURL,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
@@ -1336,6 +1524,64 @@ func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bu
|
||||
return object, file, nil
|
||||
}
|
||||
|
||||
// ErrPresignUnsupported — the configured storage driver can't mint signed
|
||||
// URLs (only the S3 driver can). Handlers map it to 400, not 500.
|
||||
var ErrPresignUnsupported = errors.New("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
|
||||
|
||||
// InputError marks a service-layer failure caused by caller input — the
|
||||
// HTTP layer maps it to 400 instead of 500.
|
||||
type InputError struct{ msg string }
|
||||
|
||||
func (e *InputError) Error() string { return e.msg }
|
||||
|
||||
func NewInputError(msg string) error { return &InputError{msg: msg} }
|
||||
|
||||
func inputErrorf(format string, args ...any) error {
|
||||
return &InputError{msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// PresignObject mints a short-lived direct-to-storage URL for a single
|
||||
// object. Requires the s3 storage driver — the local store has no external
|
||||
// URL surface to sign against.
|
||||
func (s *PlatformService) PresignObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, op string, ttlSeconds int) (*storage.PresignedURL, error) {
|
||||
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
method := ""
|
||||
switch op {
|
||||
case "download", "":
|
||||
method = http.MethodGet
|
||||
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !objectExists(ctx, s, bucketID, objectKey) {
|
||||
return nil, pgx.ErrNoRows
|
||||
}
|
||||
case "upload":
|
||||
method = http.MethodPut
|
||||
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid op %q — download or upload", op)
|
||||
}
|
||||
p, ok := s.store.(storage.Presigner)
|
||||
if !ok {
|
||||
return nil, ErrPresignUnsupported
|
||||
}
|
||||
ttl := time.Duration(ttlSeconds) * time.Second
|
||||
return p.Presign(ctx, bucketID.String(), objectKey, method, ttl)
|
||||
}
|
||||
|
||||
func objectExists(ctx context.Context, s *PlatformService, bucketID uuid.UUID, key string) bool {
|
||||
_, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
|
||||
BucketID: bucketID,
|
||||
ObjectKey: key,
|
||||
})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
|
||||
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -31,6 +32,8 @@ const (
|
||||
WebhookEventIssueCreated = "issue.created"
|
||||
WebhookEventDeployMarker = "deploy.marker"
|
||||
WebhookEventTest = "webhook.test"
|
||||
WebhookEventAlertFired = "alert.fired"
|
||||
WebhookEventAlertResolved = "alert.resolved"
|
||||
WebhookEventDocumentCreated = "document.created"
|
||||
WebhookEventDocumentUpdated = "document.updated"
|
||||
WebhookEventDocumentDeleted = "document.deleted"
|
||||
@@ -43,6 +46,9 @@ var webhookEventTypes = map[string]bool{
|
||||
WebhookEventIssueCreated: true,
|
||||
WebhookEventDeployMarker: true,
|
||||
WebhookEventTest: true,
|
||||
WebhookEventAlertFired: true,
|
||||
WebhookEventAlertResolved: true,
|
||||
WebhookEventInboundReceived: true,
|
||||
WebhookEventJobRun: true,
|
||||
WebhookEventDocumentCreated: true,
|
||||
WebhookEventDocumentUpdated: true,
|
||||
@@ -160,7 +166,7 @@ func normalizeWebhookURL(raw string) (string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
u, err := url.Parse(trimmed)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", errors.New("webhook url must be an https URL")
|
||||
return "", inputErrorf("webhook url must be an https URL")
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return trimmed, nil
|
||||
@@ -168,17 +174,35 @@ func normalizeWebhookURL(raw string) (string, error) {
|
||||
if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) {
|
||||
return trimmed, nil
|
||||
}
|
||||
return "", errors.New("webhook url must be https (http allowed only for private/self-hosted targets)")
|
||||
return "", inputErrorf("webhook url must be https (http allowed only for private/self-hosted targets)")
|
||||
}
|
||||
|
||||
var decimalOrHexIPRe = regexp.MustCompile(`^(0x[0-9a-f]+|[0-9]+)$`)
|
||||
|
||||
// metadataAddrs are link-local addresses that serve cloud instance
|
||||
// metadata — "private" by range but a privileged SSRF target.
|
||||
var metadataAddrs = map[string]bool{
|
||||
"169.254.169.254": true, // AWS/GCP/Azure
|
||||
"fd00:ec2::254": true, // AWS IPv6 metadata
|
||||
}
|
||||
|
||||
func isPrivateWebhookHost(host string) bool {
|
||||
h := strings.ToLower(strings.Trim(host, "[]"))
|
||||
if metadataAddrs[h] {
|
||||
return false
|
||||
}
|
||||
if h == "localhost" || h == "host.docker.internal" ||
|
||||
strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(h, ".") && !strings.Contains(h, ":") {
|
||||
return true // bare hostname — docker service name, unqualified LAN host
|
||||
// Bare hostname — docker service name, unqualified LAN host. A
|
||||
// pure decimal/hex integer is never a service name: it's a
|
||||
// non-canonical IP encoding (2130706433 = 127.0.0.1, 0x7f000001).
|
||||
if decimalOrHexIPRe.MatchString(h) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(h)
|
||||
if ip == nil {
|
||||
@@ -187,16 +211,40 @@ func isPrivateWebhookHost(host string) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
|
||||
}
|
||||
|
||||
// webhookEventNamespaces are the valid `<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) {
|
||||
out := make([]string, 0, len(events))
|
||||
seen := map[string]bool{}
|
||||
namespaces := webhookEventNamespaces()
|
||||
for _, e := range events {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if !webhookEventTypes[e] {
|
||||
return nil, fmt.Errorf("unknown event type %q", e)
|
||||
valid := webhookEventTypes[e] || e == "*"
|
||||
if !valid && strings.HasSuffix(e, ".*") {
|
||||
valid = namespaces[strings.TrimSuffix(e, ".*")]
|
||||
}
|
||||
if !valid && strings.HasPrefix(e, "custom.") {
|
||||
// `*` isn't in the type grammar — probe it as a literal.
|
||||
valid = customEventTypeRe.MatchString(strings.ReplaceAll(e, "*", "x"))
|
||||
}
|
||||
if !valid {
|
||||
return nil, inputErrorf("invalid event type %q — use a known type, <namespace>.*, custom.<pattern>, or *", e)
|
||||
}
|
||||
if !seen[e] {
|
||||
seen[e] = true
|
||||
|
||||
@@ -65,7 +65,7 @@ func (s *LocalStore) Put(ctx context.Context, bucketID, objectKey string, reader
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *LocalStore) Open(bucketID, objectKey string) (*os.File, string, error) {
|
||||
func (s *LocalStore) Open(bucketID, objectKey string) (io.ReadCloser, string, error) {
|
||||
cleanKey, err := sanitizeObjectKey(objectKey)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -68,6 +68,22 @@ paths:
|
||||
$ref: "#/components/schemas/MeResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/context:
|
||||
get:
|
||||
tags: [Platform]
|
||||
operationId: getActorContext
|
||||
summary: Caller scope — self-discovery for API keys, which cannot call /me
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Actor type, scopes, and resolved org/project for API keys
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ActorContext"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/instance/public:
|
||||
get:
|
||||
tags: [Instance]
|
||||
@@ -677,6 +693,37 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BucketObject"
|
||||
/buckets/{bucketID}/object-presigns:
|
||||
post:
|
||||
tags: [Storage]
|
||||
operationId: presignBucketObject
|
||||
description: >-
|
||||
Mint a short-lived URL the client calls against object storage
|
||||
directly — keeps large transfers off the backend. Requires
|
||||
BACKEND_STORAGE_DRIVER=s3; the URL's host comes from
|
||||
S3_PUBLIC_ENDPOINT (or S3_ENDPOINT when unset). Signatures bind the
|
||||
host — a presigned URL is valid only at the endpoint it was minted
|
||||
for.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/BucketID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PresignObjectRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Presigned URL
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PresignedURL"
|
||||
"404":
|
||||
description: Bucket or object not found (download)
|
||||
/buckets/{bucketID}:
|
||||
patch:
|
||||
tags: [Storage]
|
||||
@@ -1300,6 +1347,37 @@ paths:
|
||||
$ref: "#/components/schemas/TelemetryEventListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
post:
|
||||
tags: [Automation]
|
||||
operationId: publishProjectEvent
|
||||
summary: Publishes a client-authored domain event. Type must be in the custom.* namespace; the event fans out to realtime subscribers, matching webhooks, and event_pattern functions.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishEventRequest"
|
||||
responses:
|
||||
"202":
|
||||
description: Event accepted for fan-out
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
published:
|
||||
type: string
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"403":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/issues:
|
||||
get:
|
||||
tags: [Telemetry]
|
||||
@@ -1856,6 +1934,28 @@ paths:
|
||||
description: text/event-stream of {type, occurred_at, data} messages
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/realtime/presence:
|
||||
get:
|
||||
tags: [Automation]
|
||||
operationId: getRealtimePresence
|
||||
summary: Count of clients currently subscribed to the project's realtime stream. The stream also emits presence.update events on every join/leave.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
responses:
|
||||
"200":
|
||||
description: Online subscriber count
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
online:
|
||||
type: integer
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/secrets:
|
||||
get:
|
||||
tags: [Secrets]
|
||||
@@ -1941,6 +2041,408 @@ paths:
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/alerts:
|
||||
get:
|
||||
tags: [Alerts]
|
||||
operationId: listAlertRules
|
||||
summary: Lists telemetry alert rules for the project.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
responses:
|
||||
"200":
|
||||
description: Alert rules
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertRuleListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
post:
|
||||
tags: [Alerts]
|
||||
operationId: createAlertRule
|
||||
summary: Creates an alert rule. Rules fire `alert.fired`/`alert.resolved` domain events on state transitions — subscribe a webhook to those event types to receive them.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpsertAlertRuleRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Alert rule created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertRule"
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/alerts/{ruleID}:
|
||||
put:
|
||||
tags: [Alerts]
|
||||
operationId: updateAlertRule
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: ruleID
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpsertAlertRuleRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Alert rule updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertRule"
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
delete:
|
||||
tags: [Alerts]
|
||||
operationId: deleteAlertRule
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: ruleID
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
"204":
|
||||
description: Alert rule deleted
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/inbound-hooks:
|
||||
get:
|
||||
tags: [Inbound]
|
||||
operationId: listInboundHooks
|
||||
summary: Lists inbound webhook endpoints for the project.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
responses:
|
||||
"200":
|
||||
description: Inbound hooks
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundHookListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
post:
|
||||
tags: [Inbound]
|
||||
operationId: createInboundHook
|
||||
summary: Creates an inbound hook. `event` mode republishes bodies as `inbound.received` domain events; `job` mode triggers a scheduled-job run with the body as its payload. Set `secret` to require `X-Primora-Signature` (HMAC-SHA256 over the raw body).
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateInboundHookRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Inbound hook created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundHook"
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/inbound-hooks/{hookID}:
|
||||
delete:
|
||||
tags: [Inbound]
|
||||
operationId: deleteInboundHook
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: hookID
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
"204":
|
||||
description: Inbound hook deleted
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/hooks/{token}:
|
||||
post:
|
||||
tags: [Inbound]
|
||||
operationId: receiveInboundHook
|
||||
summary: Public ingest endpoint — the URL token is the credential. Body must be JSON (max 256 KiB); sign with X-Primora-Signature when the hook has a secret.
|
||||
security: []
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: X-Primora-Signature
|
||||
in: header
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"202":
|
||||
description: Accepted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundReceiveResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/emails:
|
||||
get:
|
||||
tags: [Email]
|
||||
operationId: listEmailLog
|
||||
summary: Recent transactional emails sent for this project (invitations etc.) — every send is logged with its outcome.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 50
|
||||
maximum: 200
|
||||
responses:
|
||||
"200":
|
||||
description: Email log entries
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EmailLogListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/functions:
|
||||
get:
|
||||
tags: [Functions]
|
||||
operationId: listFunctions
|
||||
summary: List functions in a project.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
responses:
|
||||
"200":
|
||||
description: Functions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/FunctionListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
post:
|
||||
tags: [Functions]
|
||||
operationId: createFunction
|
||||
summary: Create a function (name, JS/TS code, runtime bun|deno).
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateFunctionRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Created function
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Function"
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/functions/{functionID}:
|
||||
get:
|
||||
tags: [Functions]
|
||||
operationId: getFunction
|
||||
summary: Function metadata plus its source code.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: functionID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
responses:
|
||||
"200":
|
||||
description: Function with code
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/FunctionDetailResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
put:
|
||||
tags: [Functions]
|
||||
operationId: updateFunction
|
||||
summary: Update function code and/or enabled state.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: functionID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateFunctionRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated function
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Function"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
delete:
|
||||
tags: [Functions]
|
||||
operationId: deleteFunction
|
||||
summary: Delete a function and its run history.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: functionID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
responses:
|
||||
"204":
|
||||
description: Deleted
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/functions/{functionID}/invoke:
|
||||
post:
|
||||
tags: [Functions]
|
||||
operationId: invokeFunction
|
||||
summary: Execute the function with a JSON payload on stdin; returns the recorded run. 502 when the run fails or times out.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: functionID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InvokeFunctionRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Recorded run (status success)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/FunctionRun"
|
||||
"502":
|
||||
description: Recorded run (status error|timeout)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/FunctionRun"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/functions/{functionID}/runs:
|
||||
get:
|
||||
tags: [Functions]
|
||||
operationId: listFunctionRuns
|
||||
summary: Recent runs for a function.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: functionID
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 50
|
||||
maximum: 200
|
||||
responses:
|
||||
"200":
|
||||
description: Function runs
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/FunctionRunListResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -2290,6 +2792,34 @@ components:
|
||||
items:
|
||||
$ref: "#/components/schemas/OrganizationSummary"
|
||||
required: [user, organizations]
|
||||
ActorContext:
|
||||
type: object
|
||||
properties:
|
||||
actor:
|
||||
type: string
|
||||
enum: [user, api_key]
|
||||
scopes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
key_prefix:
|
||||
type: string
|
||||
organization:
|
||||
$ref: "#/components/schemas/ContextRef"
|
||||
project:
|
||||
$ref: "#/components/schemas/ContextRef"
|
||||
required: [actor]
|
||||
ContextRef:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
slug:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
required: [id, slug, name]
|
||||
OrganizationMembership:
|
||||
type: object
|
||||
properties:
|
||||
@@ -2728,6 +3258,48 @@ components:
|
||||
format: uuid
|
||||
nullable: true
|
||||
required: [objectKey, newObjectKey]
|
||||
PresignObjectRequest:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
description: Object key inside the bucket.
|
||||
op:
|
||||
type: string
|
||||
enum: [download, upload]
|
||||
default: download
|
||||
description: download → GET URL (object must exist); upload → PUT URL (creates/overwrites).
|
||||
ttl_seconds:
|
||||
type: integer
|
||||
minimum: 60
|
||||
maximum: 3600
|
||||
default: 900
|
||||
required: [key]
|
||||
PresignedURL:
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
description: Fully-signed URL — call it with the returned method, no auth header needed.
|
||||
method:
|
||||
type: string
|
||||
enum: [GET, PUT]
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [url, method, expires_at]
|
||||
PublishEventRequest:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
pattern: '^custom\.[a-z0-9][a-z0-9_.-]{0,62}$'
|
||||
description: Event type in the custom.* namespace — realtime subscribers, matching webhooks, and event_pattern functions all fire.
|
||||
data:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Arbitrary JSON payload carried to subscribers.
|
||||
required: [type]
|
||||
PaginationMetadata:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3512,7 +4084,7 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Event filter (issue.created, deploy.marker, webhook.test, job.run, document.*, object.*). Empty means all events.
|
||||
description: Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.*, object.*). Empty means all events.
|
||||
enabled:
|
||||
type: boolean
|
||||
has_secret:
|
||||
@@ -3534,7 +4106,7 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
|
||||
enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
|
||||
enabled:
|
||||
type: boolean
|
||||
required: [url]
|
||||
@@ -3551,7 +4123,7 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
|
||||
enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
|
||||
enabled:
|
||||
type: boolean
|
||||
WebhookCreateResponse:
|
||||
@@ -3656,6 +4228,11 @@ components:
|
||||
description: Cron expression (5-field) or descriptor (@hourly, @every 30m).
|
||||
url:
|
||||
type: string
|
||||
function_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: When set, the schedule invokes this function instead of POSTing url.
|
||||
payload:
|
||||
type: object
|
||||
description: Static JSON payload merged into every delivery's data field.
|
||||
@@ -3691,6 +4268,11 @@ components:
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
description: Delivery target. Required unless function_id is set.
|
||||
function_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Invoke this function on schedule instead of POSTing url.
|
||||
secret:
|
||||
type: string
|
||||
description: HMAC key for X-Primora-Signature. Generated when omitted.
|
||||
@@ -3700,7 +4282,7 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
default: true
|
||||
required: [name, schedule, url]
|
||||
required: [name, schedule]
|
||||
UpdateScheduledJobRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3712,6 +4294,11 @@ components:
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
function_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: Set to retarget at a function; send "" to clear back to url delivery.
|
||||
secret:
|
||||
type: string
|
||||
description: Replace the signing secret. Omit to keep the current one.
|
||||
@@ -3821,3 +4408,275 @@ components:
|
||||
value:
|
||||
type: string
|
||||
required: [name, value]
|
||||
AlertRuleConfig:
|
||||
type: object
|
||||
properties:
|
||||
component:
|
||||
type: string
|
||||
description: Restrict the rule to one component name; empty = all components.
|
||||
threshold_minutes:
|
||||
type: integer
|
||||
description: heartbeat_silence — fire when a component has not heartbeated for this many minutes (1–10080).
|
||||
threshold_count:
|
||||
type: integer
|
||||
format: int64
|
||||
description: error_spike — fire when a component logs at least this many errors inside the window.
|
||||
window_minutes:
|
||||
type: integer
|
||||
description: error_spike — sliding window size (default 15, 1–1440).
|
||||
AlertRule:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
project_id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [heartbeat_silence, error_spike]
|
||||
config:
|
||||
$ref: "#/components/schemas/AlertRuleConfig"
|
||||
enabled:
|
||||
type: boolean
|
||||
firing:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Component names currently in breached state.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [id, project_id, name, kind, config, enabled, firing, created_at, updated_at]
|
||||
AlertRuleListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertRule"
|
||||
required: [items]
|
||||
UpsertAlertRuleRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Env-var-safe name ([A-Za-z_][A-Za-z0-9_]*).
|
||||
kind:
|
||||
type: string
|
||||
enum: [heartbeat_silence, error_spike]
|
||||
config:
|
||||
$ref: "#/components/schemas/AlertRuleConfig"
|
||||
enabled:
|
||||
type: boolean
|
||||
required: [name, kind]
|
||||
InboundHook:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
project_id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
description: Full ingest URL — POST here from the external system.
|
||||
mode:
|
||||
type: string
|
||||
enum: [event, job, function]
|
||||
job_id:
|
||||
type: string
|
||||
function_id:
|
||||
type: string
|
||||
has_secret:
|
||||
type: boolean
|
||||
enabled:
|
||||
type: boolean
|
||||
last_received_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [id, project_id, name, token, url, mode, has_secret, enabled, created_at]
|
||||
InboundHookListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InboundHook"
|
||||
required: [items]
|
||||
CreateInboundHookRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
mode:
|
||||
type: string
|
||||
enum: [event, job, function]
|
||||
default: event
|
||||
job_id:
|
||||
type: string
|
||||
description: Required when mode=job — scheduled job to trigger.
|
||||
function_id:
|
||||
type: string
|
||||
description: Required when mode=function — invoked with the received body.
|
||||
secret:
|
||||
type: string
|
||||
description: Optional — stored encrypted; callers must then sign bodies with X-Primora-Signature.
|
||||
enabled:
|
||||
type: boolean
|
||||
required: [name]
|
||||
InboundReceiveResponse:
|
||||
type: object
|
||||
properties:
|
||||
received:
|
||||
type: boolean
|
||||
event:
|
||||
type: string
|
||||
run_id:
|
||||
type: string
|
||||
required: [received]
|
||||
EmailLogEntry:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
template:
|
||||
type: string
|
||||
to_email:
|
||||
type: string
|
||||
subject:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [sent, failed]
|
||||
error:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [id, template, to_email, subject, status, created_at]
|
||||
EmailLogListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/EmailLogEntry"
|
||||
required: [items]
|
||||
Function:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
runtime:
|
||||
type: string
|
||||
enum: [bun, deno]
|
||||
enabled:
|
||||
type: boolean
|
||||
event_pattern:
|
||||
type: string
|
||||
description: 'Domain-event pattern that triggers this function — "document.*", "*", exact type. Empty = never.'
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [id, name, runtime, enabled, created_at, updated_at]
|
||||
FunctionListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Function"
|
||||
required: [items]
|
||||
FunctionDetailResponse:
|
||||
type: object
|
||||
properties:
|
||||
function:
|
||||
$ref: "#/components/schemas/Function"
|
||||
code:
|
||||
type: string
|
||||
required: [function, code]
|
||||
CreateFunctionRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
code:
|
||||
type: string
|
||||
runtime:
|
||||
type: string
|
||||
enum: [bun, deno]
|
||||
default: bun
|
||||
event_pattern:
|
||||
type: string
|
||||
description: 'Optional — "document.*", "*", or an exact event type.'
|
||||
required: [name, code]
|
||||
UpdateFunctionRequest:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
event_pattern:
|
||||
type: string
|
||||
description: Replace the trigger pattern; "" disables event triggers.
|
||||
InvokeFunctionRequest:
|
||||
type: object
|
||||
properties:
|
||||
payload: {}
|
||||
FunctionRun:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
trigger:
|
||||
type: string
|
||||
enum: [manual, schedule, hook, event]
|
||||
status:
|
||||
type: string
|
||||
enum: [success, error, timeout]
|
||||
exit_code:
|
||||
type: integer
|
||||
nullable: true
|
||||
stdout:
|
||||
type: string
|
||||
stderr:
|
||||
type: string
|
||||
duration_ms:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [id, trigger, status, stdout, stderr, duration_ms, created_at]
|
||||
FunctionRunListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/FunctionRun"
|
||||
required: [items]
|
||||
+8
-1
@@ -21,7 +21,14 @@ project interactively.
|
||||
## Commands
|
||||
|
||||
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`,
|
||||
`jobs`, `audit list --follow`. Every command accepts `--json`.
|
||||
`jobs`, `events:send`, `audit list --follow`. Every command accepts `--json`.
|
||||
|
||||
```sh
|
||||
primora events:send custom.deploy.done --data '{"sha":"abc123"}'
|
||||
```
|
||||
|
||||
`events:send` publishes a `custom.*` event — realtime subscribers, matching
|
||||
webhooks, and `event_pattern` functions all fire.
|
||||
|
||||
```sh
|
||||
primora documents list --collection users \
|
||||
|
||||
+79
-3
@@ -3,7 +3,8 @@ import { cac } from "cac";
|
||||
import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
|
||||
import { cmdAuditList } from "./commands/audit.js";
|
||||
import { cmdDocumentsList } from "./commands/documents.js";
|
||||
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
|
||||
import { cmdEventsSend } from "./commands/events.js";
|
||||
import { cmdBucketsCreate, cmdBucketsList, cmdBucketsRemove } from "./commands/buckets.js";
|
||||
import { cmdContext, cmdUse } from "./commands/context.js";
|
||||
import { cmdInject } from "./commands/inject.js";
|
||||
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
|
||||
@@ -12,10 +13,11 @@ import {
|
||||
cmdObjectsDownload,
|
||||
cmdObjectsList,
|
||||
cmdObjectsRemove,
|
||||
cmdObjectsPresign,
|
||||
cmdObjectsUpload,
|
||||
} from "./commands/objects.js";
|
||||
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
|
||||
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js";
|
||||
import { cmdOrgsCreate, cmdOrgsList, cmdOrgsRemove } from "./commands/orgs.js";
|
||||
import { cmdProjectsCreate, cmdProjectsList, cmdProjectsRemove } from "./commands/projects.js";
|
||||
import {
|
||||
cmdSecretsGet,
|
||||
cmdSecretsImport,
|
||||
@@ -50,6 +52,7 @@ cli
|
||||
.option("--email <email>", "Account email")
|
||||
.option("--password <password>", "Account password")
|
||||
.option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(cmdLogin);
|
||||
|
||||
cli.command("logout", "Remove stored credentials").action(cmdLogout);
|
||||
@@ -68,6 +71,10 @@ cli
|
||||
.command("orgs:create <name>", "Create an organization")
|
||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||
.action(cmdOrgsCreate);
|
||||
cli
|
||||
.command("orgs:rm <org>", "Delete an organization (id, slug, or name)")
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdOrgsRemove);
|
||||
|
||||
cli
|
||||
.command("projects:list", "List projects in the current org")
|
||||
@@ -80,6 +87,10 @@ cli
|
||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||
.option("--description <text>", "Description")
|
||||
.action(cmdProjectsCreate);
|
||||
cli
|
||||
.command("projects:rm <project>", "Delete a project (id, slug, or name)")
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdProjectsRemove);
|
||||
|
||||
cli
|
||||
.command("buckets:list", "List buckets in the current project")
|
||||
@@ -92,6 +103,11 @@ cli
|
||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||
.option("--public", "Public visibility (default private)")
|
||||
.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
|
||||
.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")
|
||||
.option("--project <id>", "Project override")
|
||||
.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
|
||||
.command("keys:list", "List API keys for the current project")
|
||||
@@ -167,6 +189,12 @@ cli
|
||||
.option("--offset <n>", "Page offset")
|
||||
.action(cmdDocumentsList);
|
||||
|
||||
cli
|
||||
.command("events:send <type>", "Publish a custom.* event to realtime subscribers, webhooks, and functions")
|
||||
.option("--project <id>", "Project override")
|
||||
.option("--data <json>", "JSON object payload")
|
||||
.action(cmdEventsSend);
|
||||
|
||||
cli
|
||||
.command("audit:list", "List audit events for the current project")
|
||||
.option("--project <id>", "Project override")
|
||||
@@ -317,9 +345,57 @@ if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
|
||||
argv[0] = "secrets:list";
|
||||
}
|
||||
|
||||
// mri coerces numeric-looking option values ("007" → 7, "" → 0), which
|
||||
// corrupts secrets, keys, and passwords. Re-extract raw strings for every
|
||||
// non-boolean flag and patch parsed options before the action runs.
|
||||
function restoreRawOptionStrings(): void {
|
||||
const cmd = (cli as unknown as { matchedCommand?: { options: { isBoolean: boolean; name: string; names: string[] }[] } })
|
||||
.matchedCommand;
|
||||
if (!cmd) return;
|
||||
const state = cli as unknown as { options: Record<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() {
|
||||
try {
|
||||
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();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
|
||||
@@ -10,6 +10,7 @@ interface LoginOptions {
|
||||
email?: string;
|
||||
password?: string;
|
||||
apiKey?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function cancel(): never {
|
||||
@@ -39,9 +40,22 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
|
||||
throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…");
|
||||
}
|
||||
cfg.auth = { type: "apiKey", key: options.apiKey };
|
||||
// Validate the key against the instance before saving — a stored-but-
|
||||
// dead key is a worse failure than a rejected login.
|
||||
configureClient(cfg);
|
||||
const ctx = await PlatformService.getActorContext();
|
||||
if (ctx.organization) cfg.organizationId = ctx.organization.id;
|
||||
if (ctx.project) cfg.projectId = ctx.project.id;
|
||||
saveConfig(cfg);
|
||||
success(`Saved API key credentials to ${configFilePath()}`);
|
||||
dim("API keys are project-scoped — set context with `primora use --org <id> --project <id>`.");
|
||||
if (isJson(options)) {
|
||||
printJson(ctx);
|
||||
return;
|
||||
}
|
||||
success(`API key verified — ${ctx.key_prefix}… (scopes: ${ctx.scopes?.join(", ") || "none"})`);
|
||||
if (ctx.organization && ctx.project) {
|
||||
success(`Context → ${ctx.organization.slug} / ${ctx.project.slug}`);
|
||||
}
|
||||
dim(`Config written to ${configFilePath()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,6 +108,10 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
|
||||
|
||||
configureClient(cfg);
|
||||
const me = await PlatformService.getMe();
|
||||
if (isJson(options)) {
|
||||
printJson(me);
|
||||
return;
|
||||
}
|
||||
success(`Signed in as ${me.user.email}`);
|
||||
dim(`Config written to ${configFilePath()}`);
|
||||
}
|
||||
@@ -108,6 +126,23 @@ export async function cmdLogout(): Promise<void> {
|
||||
export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
configureClient(cfg);
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
const ctx = await PlatformService.getActorContext();
|
||||
if (isJson(options)) {
|
||||
printJson(ctx);
|
||||
return;
|
||||
}
|
||||
printTable(["KEY", "SCOPES", "ORGANIZATION", "PROJECT"], [
|
||||
[
|
||||
`${ctx.key_prefix}…`,
|
||||
ctx.scopes?.join(", ") || "—",
|
||||
ctx.organization?.slug ?? "—",
|
||||
ctx.project?.slug ?? "—",
|
||||
],
|
||||
]);
|
||||
dim(`\n${cfg.baseUrl} · auth: api_key`);
|
||||
return;
|
||||
}
|
||||
const me = await PlatformService.getMe();
|
||||
|
||||
if (isJson(options)) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CreateBucketRequest, StorageService } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import { configureClient, requireAuth, requireProject } from "../http.js";
|
||||
import { isJson, printJson, printTable, success } from "../out.js";
|
||||
import { configureClient, requireAuth, requireProject, resolveBucketId } from "../http.js";
|
||||
import { confirmOrAbort, isJson, printJson, printTable, success } from "../out.js";
|
||||
|
||||
export async function cmdBucketsList(options: {
|
||||
project?: string;
|
||||
@@ -54,3 +54,22 @@ export async function cmdBucketsCreate(
|
||||
}
|
||||
success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`);
|
||||
}
|
||||
|
||||
export async function cmdBucketsRemove(
|
||||
bucketRef: string,
|
||||
options: { project?: string; yes?: boolean; json?: boolean },
|
||||
): Promise<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}`);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,22 @@ export async function cmdUse(options: UseOptions): Promise<void> {
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
// API keys are bound to one org+project — resolve the scope directly.
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
const ctx = await PlatformService.getActorContext();
|
||||
if (options.org && ctx.organization && options.org !== ctx.organization.id && options.org !== ctx.organization.slug) {
|
||||
throw new Error(`API key is scoped to ${ctx.organization.slug} — cannot switch to "${options.org}".`);
|
||||
}
|
||||
if (options.project && ctx.project && options.project !== ctx.project.id && options.project !== ctx.project.slug) {
|
||||
throw new Error(`API key is scoped to ${ctx.project.slug} — cannot switch to "${options.project}".`);
|
||||
}
|
||||
if (ctx.organization) cfg.organizationId = ctx.organization.id;
|
||||
if (ctx.project) cfg.projectId = ctx.project.id;
|
||||
saveConfig(cfg);
|
||||
success(`Context → ${ctx.organization?.slug ?? "—"} / ${ctx.project?.slug ?? "—"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const me = await PlatformService.getMe();
|
||||
const orgs = me.organizations;
|
||||
if (orgs.length === 0) {
|
||||
@@ -77,6 +93,19 @@ export async function cmdContext(options: { json?: boolean }): Promise<void> {
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
const ctx = await PlatformService.getActorContext();
|
||||
if (isJson(options)) {
|
||||
printJson({ baseUrl: cfg.baseUrl, auth: "apiKey", ...ctx });
|
||||
return;
|
||||
}
|
||||
dim(`url ${cfg.baseUrl}`);
|
||||
dim(`auth api_key (${ctx.key_prefix}…, scopes: ${ctx.scopes?.join(", ") || "none"})`);
|
||||
process.stdout.write(`org ${ctx.organization ? `${ctx.organization.name} (${ctx.organization.slug})` : "—"}\n`);
|
||||
process.stdout.write(`project ${ctx.project ? `${ctx.project.name} (${ctx.project.slug})` : "—"}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const me = await PlatformService.getMe();
|
||||
const org = me.organizations.find((o) => o.id === cfg.organizationId);
|
||||
const project = org?.projects.find((pr) => pr.id === cfg.projectId);
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { statSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
|
||||
import { StorageService } from "@primora/api-client";
|
||||
import { PresignObjectRequest, StorageService } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import {
|
||||
@@ -133,3 +133,31 @@ export async function cmdObjectsRemove(
|
||||
await StorageService.deleteBucketObject({ bucketId, objectKey });
|
||||
success(`Deleted ${objectKey}`);
|
||||
}
|
||||
|
||||
export async function cmdObjectsPresign(
|
||||
bucketRef: string,
|
||||
objectKey: string,
|
||||
options: { project?: string; upload?: boolean; ttl?: string; json?: boolean },
|
||||
): Promise<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}`);
|
||||
}
|
||||
@@ -38,3 +38,31 @@ export async function cmdOrgsCreate(
|
||||
}
|
||||
success(`Organization "${org.name}" created (${org.slug})`);
|
||||
}
|
||||
|
||||
export async function cmdOrgsRemove(
|
||||
orgRef: string,
|
||||
options: { yes?: boolean; json?: boolean },
|
||||
): Promise<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);
|
||||
}
|
||||
@@ -50,3 +50,34 @@ export async function cmdProjectsCreate(
|
||||
}
|
||||
success(`Project "${project.name}" created (${project.slug})`);
|
||||
}
|
||||
|
||||
export async function cmdProjectsRemove(
|
||||
projectRef: string,
|
||||
options: { org?: string; yes?: boolean; json?: boolean },
|
||||
): Promise<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);
|
||||
}
|
||||
@@ -60,6 +60,8 @@ export function configureClient(cfg: CliConfig): void {
|
||||
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
|
||||
OpenAPI.HEADERS = undefined;
|
||||
OpenAPI.TOKEN = undefined;
|
||||
// encodeURI leaves ?#& intact in path params — smuggles query segments.
|
||||
OpenAPI.ENCODE_PATH = encodeURIComponent;
|
||||
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
|
||||
|
||||
+19
-1
@@ -17,9 +17,27 @@ export function dim(message: string): void {
|
||||
process.stdout.write(pc.dim(message) + "\n");
|
||||
}
|
||||
|
||||
/** Destructive-action gate — requires --yes in scripts, prompts on a TTY. */
|
||||
export async function confirmOrAbort(message: string, yes?: boolean): Promise<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 {
|
||||
const err = error as { message?: string; hint?: string; status?: number; body?: unknown };
|
||||
const err = error as { message?: string; hint?: string; status?: number; body?: unknown; cause?: { code?: string } };
|
||||
let message = err?.message ?? "unknown error";
|
||||
// undici TypeError "fetch failed" — the useful part is the cause code.
|
||||
if (message === "fetch failed" || message.startsWith("fetch failed")) {
|
||||
const code = err?.cause?.code;
|
||||
message = `could not reach the instance${code ? ` (${code})` : ""} — check \`primora context\` and that it is running`;
|
||||
}
|
||||
// generated client throws ApiError with a parsed body
|
||||
const body = err?.body as { error?: { message?: string } } | undefined;
|
||||
if (body?.error?.message) message = body.error.message;
|
||||
|
||||
@@ -44,3 +44,21 @@ file, one crypto implementation — terminal and desktop always agree. Requires
|
||||
Injection and the credential broker stay CLI-only (`primora inject`,
|
||||
`primora agent`).
|
||||
- Deep links / push notifications: deliberately omitted — nothing needs them yet.
|
||||
|
||||
## Auto-updates
|
||||
|
||||
`tauri-plugin-updater` checks `releases/latest/download/latest.json` on the
|
||||
GitHub repo when the connect screen loads; if a newer signed release exists
|
||||
it offers "Update & restart" — download, minisign verification, install,
|
||||
relaunch. No update manifest or signature failure = silent no-op; unsigned
|
||||
artifacts can never install.
|
||||
|
||||
Release side (already wired in `.github/workflows/release.yml`): the desktop
|
||||
job signs updater artifacts with `TAURI_SIGNING_PRIVATE_KEY` (+ optional
|
||||
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`) repo secrets, uploads the `.sig`
|
||||
files, and the release job assembles `latest.json` from them. Without the
|
||||
secret the build still succeeds but emits no signatures — and no manifest.
|
||||
|
||||
The matching public key is embedded in `tauri.conf.json`. The private key
|
||||
lives only in repo secrets and on the maintainer's machine — regenerate with
|
||||
`npx tauri signer generate` and update both if it is ever lost.
|
||||
Generated
+457
-8
@@ -47,6 +47,15 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -506,6 +515,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -529,7 +548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -542,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -713,6 +732,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -996,6 +1026,16 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
@@ -1254,6 +1294,17 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1530,6 +1581,21 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1548,9 +1614,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1847,6 +1915,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.20",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -2036,6 +2134,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2288,6 +2392,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2303,6 +2408,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2366,6 +2483,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2382,6 +2505,20 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2615,7 +2752,9 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2804,15 +2943,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2824,6 +2968,20 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -2852,6 +3010,79 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -2867,6 +3098,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -2924,6 +3164,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
@@ -3145,6 +3408,22 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -3257,6 +3536,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.8"
|
||||
@@ -3320,6 +3605,27 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3341,7 +3647,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -3351,7 +3657,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -3384,6 +3690,17 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -3408,7 +3725,7 @@ dependencies = [
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"image",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -3504,6 +3821,32 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.4"
|
||||
@@ -3520,6 +3863,39 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -3530,7 +3906,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -3553,7 +3929,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -3742,6 +4118,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
@@ -4055,6 +4441,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4298,6 +4690,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -4483,6 +4884,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -4528,6 +4940,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -4823,7 +5244,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -4870,6 +5291,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
@@ -4984,6 +5415,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
@@ -5017,6 +5454,18 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.14.2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
||||
@@ -15,5 +15,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -3,5 +3,5 @@
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for the main window",
|
||||
"windows": ["main", "vault"],
|
||||
"permissions": ["core:default"]
|
||||
"permissions": ["core:default", "updater:default", "process:default"]
|
||||
}
|
||||
@@ -200,6 +200,8 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
show_main(app);
|
||||
}))
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_server_url,
|
||||
set_server_url,
|
||||
|
||||
@@ -23,9 +23,18 @@
|
||||
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http:; img-src 'self' data:; style-src 'self'"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVERjFDMzI1MURGMTlBNDAKUldSQW12RWRKY1B4WFpQN2N0RjdCemllN1ptcDhObFJQMVdHL3pLTXMzNEN6TlJoMEZhQUhDNG4K",
|
||||
"endpoints": [
|
||||
"https://github.com/Dvorinka/Primora/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Primora desktop client",
|
||||
"longDescription": "Thin desktop shell for a self-hosted Primora deployment. Renders the console served by your own server.",
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
<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"><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 & restart</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
@@ -44,12 +48,52 @@
|
||||
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) {
|
||||
fail("This page is meant to run inside the Primora desktop app.");
|
||||
} else {
|
||||
// A saved deployment URL means "skip the form" on every launch.
|
||||
const saved = await tauri.core.invoke("get_server_url").catch(() => null);
|
||||
if (saved) location.replace(saved);
|
||||
// A saved deployment URL means "skip the form" on every launch —
|
||||
// unless an update is waiting, which wins the redirect.
|
||||
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) => {
|
||||
|
||||
@@ -27,13 +27,20 @@ RUN npm run build
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:1.27-alpine
|
||||
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 { \
|
||||
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 / { \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
add_header Cache-Control "no-cache, must-revalidate"; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
|
||||
@@ -16,13 +16,6 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<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>
|
||||
html { background: #0a0a0b; color-scheme: dark; }
|
||||
html.light { background: #fafaf9; color-scheme: light; }
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.3.0",
|
||||
"@fontsource/geist-mono": "^5.3.0",
|
||||
"@primora/api-client": "0.5.0",
|
||||
"@primora/shared-types": "0.5.0",
|
||||
"better-auth": "^1.5.6",
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js";
|
||||
|
||||
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 {
|
||||
AppShell,
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
TelemetryPage,
|
||||
IntegrationsPage,
|
||||
VaultPage,
|
||||
FunctionsPage,
|
||||
LoginPage,
|
||||
MembersPage,
|
||||
ProjectsPage,
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
IconDatabases,
|
||||
IconTelemetry,
|
||||
IconZap,
|
||||
IconTerminal,
|
||||
IconCollections,
|
||||
IconAudit,
|
||||
IconAuth,
|
||||
@@ -82,11 +84,7 @@ type ObjectPreview =
|
||||
| { kind: "unsupported"; message: string };
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
const message = (error as { message?: unknown }).message;
|
||||
if (typeof message === "string" && message.length > 0) return message;
|
||||
}
|
||||
return fallback;
|
||||
return errorMessage(error, fallback);
|
||||
}
|
||||
|
||||
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-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-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-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") },
|
||||
@@ -1722,6 +1721,8 @@ export default function App() {
|
||||
|
||||
<Show when={activeView() === "members"}>
|
||||
<MembersPage
|
||||
projectID={activeProject()?.id}
|
||||
demoMode={isDemo}
|
||||
organizationMembers={organizationMembers()}
|
||||
organizationInvitations={organizationInvitations()}
|
||||
projectMembers={projectMembers()}
|
||||
@@ -1782,6 +1783,14 @@ export default function App() {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "functions"}>
|
||||
<FunctionsPage
|
||||
projectID={activeProject()?.id}
|
||||
canManage={canUpdateProject()}
|
||||
demoMode={isDemo}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "storage"}>
|
||||
<StoragePage
|
||||
buckets={buckets()}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconTelemetry,
|
||||
IconIntegrations,
|
||||
IconZap,
|
||||
IconTerminal,
|
||||
IconKey,
|
||||
IconAudit,
|
||||
IconAuth,
|
||||
@@ -33,6 +34,7 @@ export type ViewType =
|
||||
| "telemetry"
|
||||
| "integrations"
|
||||
| "automation"
|
||||
| "functions"
|
||||
| "vault"
|
||||
| "auth"
|
||||
| "audit"
|
||||
@@ -51,6 +53,7 @@ const projectNav: NavEntry[] = [
|
||||
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
||||
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
|
||||
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
|
||||
{ id: "functions", label: "Functions", shortLabel: "Funcs", icon: IconTerminal },
|
||||
{ id: "vault", label: "Vault", icon: IconKey },
|
||||
{ id: "storage", label: "Storage", icon: IconStorage },
|
||||
{ id: "collections", label: "Collections", icon: IconCollections },
|
||||
@@ -99,6 +102,7 @@ const viewTitle: Record<ViewType, string> = {
|
||||
telemetry: "Telemetry",
|
||||
integrations: "Integrations",
|
||||
automation: "Automation",
|
||||
functions: "Functions",
|
||||
vault: "Vault",
|
||||
auth: "Authentication",
|
||||
audit: "Audit log",
|
||||
|
||||
@@ -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";
|
||||
|
||||
interface ModalProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -6,6 +6,7 @@ interface ModalProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
error?: JSX.Element;
|
||||
size?: "sm" | "md" | "lg" | "xl" | "full";
|
||||
closeOnEscape?: boolean;
|
||||
closeOnBackdrop?: boolean;
|
||||
@@ -26,6 +27,7 @@ export function Modal(props: ModalProps) {
|
||||
"onClose",
|
||||
"title",
|
||||
"description",
|
||||
"error",
|
||||
"size",
|
||||
"closeOnEscape",
|
||||
"closeOnBackdrop",
|
||||
@@ -38,6 +40,8 @@ export function Modal(props: ModalProps) {
|
||||
const closeOnEscape = () => local.closeOnEscape ?? true;
|
||||
const closeOnBackdrop = () => local.closeOnBackdrop ?? true;
|
||||
const showClose = () => local.showClose ?? true;
|
||||
const titleId = createUniqueId();
|
||||
const descriptionId = createUniqueId();
|
||||
|
||||
createEffect(() => {
|
||||
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"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={local.title ? "modal-title" : undefined}
|
||||
aria-describedby={local.description ? "modal-description" : undefined}
|
||||
aria-labelledby={local.title ? titleId : undefined}
|
||||
aria-describedby={local.description ? descriptionId : undefined}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<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-1">
|
||||
<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}
|
||||
</h2>
|
||||
</Show>
|
||||
<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}
|
||||
</p>
|
||||
</Show>
|
||||
@@ -116,7 +120,14 @@ export function Modal(props: ModalProps) {
|
||||
</Show>
|
||||
|
||||
{/* 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>
|
||||
</Portal>
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
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 components;
|
||||
@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");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import { OpenAPI } from "@primora/api-client";
|
||||
import { fetchApiToken } from "./auth-client";
|
||||
|
||||
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.WITH_CREDENTIALS = true;
|
||||
OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? "";
|
||||
@@ -10,3 +12,20 @@ OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? "";
|
||||
export function configureApiToken(tokenResolver?: () => Promise<string | undefined>) {
|
||||
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;
|
||||
}
|
||||
@@ -21,25 +21,63 @@ const PAGE_SIZE = 25;
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
"",
|
||||
"platform.bootstrap",
|
||||
"organization.created",
|
||||
"organization.updated",
|
||||
"organization.deleted",
|
||||
"organization.member.role_updated",
|
||||
"organization.member.removed",
|
||||
"project.created",
|
||||
"project.updated",
|
||||
"project.deleted",
|
||||
"project.member.role_updated",
|
||||
"project.member.removed",
|
||||
"invitation.created",
|
||||
"invitation.accepted",
|
||||
"invitation.revoked",
|
||||
"bucket.created",
|
||||
"bucket.updated",
|
||||
"bucket.deleted",
|
||||
"object.uploaded",
|
||||
"object.updated",
|
||||
"object.deleted",
|
||||
"object.copied",
|
||||
"object.moved",
|
||||
"api_key.created",
|
||||
"api_key.revoked",
|
||||
"member.invited",
|
||||
"member.role_updated",
|
||||
"member.removed",
|
||||
"collection.created",
|
||||
"collection.updated",
|
||||
"collection.deleted",
|
||||
"document.created",
|
||||
"document.updated",
|
||||
"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" {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js";
|
||||
import {
|
||||
AutomationService,
|
||||
FunctionsService,
|
||||
OpenAPI,
|
||||
type ScheduledJob,
|
||||
type ScheduledJobRun,
|
||||
type Function as PrimoraFunction,
|
||||
} from "@primora/api-client";
|
||||
import { fetchApiToken } from "../lib/auth-client";
|
||||
import { demoService } from "../lib/demo-mode";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Input, Textarea } from "../components/Input";
|
||||
import { errorMessage } from "../lib/api";
|
||||
import {
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
@@ -35,7 +38,7 @@ interface RealtimeEvent {
|
||||
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) =>
|
||||
(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 [expandedJob, setExpandedJob] = createSignal<string>();
|
||||
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 [loading, setLoading] = createSignal(false);
|
||||
const [busy, setBusy] = createSignal<string>();
|
||||
@@ -82,6 +86,10 @@ export function AutomationPage(props: AutomationPageProps) {
|
||||
try {
|
||||
const res = await svc(props.demoMode).listScheduledJobs({ projectId: props.projectID });
|
||||
setJobs(res.items ?? []);
|
||||
if (!props.demoMode) {
|
||||
const fr = await FunctionsService.listFunctions({ projectId: props.projectID });
|
||||
setFunctions(fr.items ?? []);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
@@ -199,14 +207,15 @@ export function AutomationPage(props: AutomationPageProps) {
|
||||
requestBody: {
|
||||
name: form.name.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,
|
||||
payload,
|
||||
enabled: form.enabled,
|
||||
},
|
||||
});
|
||||
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);
|
||||
}, "Schedule created");
|
||||
};
|
||||
@@ -335,7 +344,9 @@ export function AutomationPage(props: AutomationPageProps) {
|
||||
<code class="text-xs">{job.schedule}</code>
|
||||
</td>
|
||||
<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 class="hidden md:table-cell text-text-2">{job.enabled ? fmtTime(job.next_run_at) : "—"}</td>
|
||||
<td class="hidden md:table-cell">
|
||||
@@ -522,7 +533,7 @@ export function AutomationPage(props: AutomationPageProps) {
|
||||
</div>
|
||||
</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">
|
||||
<Input
|
||||
label="Name"
|
||||
@@ -545,16 +556,56 @@ export function AutomationPage(props: AutomationPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Target URL"
|
||||
value={jobForm().url}
|
||||
onInput={(e) => setJobForm((c) => ({ ...c, url: e.currentTarget.value }))}
|
||||
placeholder="https://api.example.com/jobs/rollup"
|
||||
required
|
||||
/>
|
||||
<p class="label-hint">
|
||||
HTTPS for public endpoints; plain HTTP allowed for private/self-hosted targets.
|
||||
</p>
|
||||
<span class="label">Target</span>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class={`btn btn-sm ${jobForm().target === "url" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setJobForm((c) => ({ ...c, target: "url" }))}
|
||||
>
|
||||
Webhook URL
|
||||
</button>
|
||||
<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>
|
||||
<Textarea
|
||||
label="Payload (optional JSON)"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { demoService } from "../lib/demo-mode";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
import { Input, Select, Textarea } from "../components/Input";
|
||||
import { errorMessage } from "../lib/api";
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconDatabases,
|
||||
@@ -57,7 +58,7 @@ const emptyForm: ConnForm = {
|
||||
const dbTypes = Object.values(CreateDBConnectionRequest.db_type);
|
||||
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 */
|
||||
const svc = (demo: boolean) =>
|
||||
@@ -933,6 +934,7 @@ export function DatabasesPage(props: DatabasesPageProps) {
|
||||
<Modal
|
||||
open={addOpen()}
|
||||
onClose={() => setAddOpen(false)}
|
||||
error={error()}
|
||||
title="Add connection"
|
||||
description="Credentials are stored server-side and never sent back to the browser."
|
||||
>
|
||||
@@ -1024,6 +1026,7 @@ export function DatabasesPage(props: DatabasesPageProps) {
|
||||
<Modal
|
||||
open={!!confirmDelete()}
|
||||
onClose={() => setConfirmDelete(undefined)}
|
||||
error={error()}
|
||||
title="Delete connection"
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
import { For, Show, createEffect, createSignal } from "solid-js";
|
||||
import {
|
||||
AlertsService,
|
||||
AutomationService,
|
||||
CreateInboundHookRequest,
|
||||
CreateIntegrationRequest,
|
||||
FunctionsService,
|
||||
InboundService,
|
||||
IntegrationsService,
|
||||
UpsertAlertRuleRequest,
|
||||
WebhooksService,
|
||||
type AlertRule,
|
||||
type Function as PrimoraFunction,
|
||||
type InboundHook,
|
||||
type ScheduledJob,
|
||||
type Integration,
|
||||
type IntegrationAnalytics,
|
||||
type Webhook,
|
||||
@@ -12,6 +22,7 @@ import { demoService } from "../lib/demo-mode";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Input, Select } from "../components/Input";
|
||||
import { errorMessage } from "../lib/api";
|
||||
import {
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
@@ -30,7 +41,7 @@ interface IntegrationsPageProps {
|
||||
|
||||
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) =>
|
||||
(demo ? (demoService as unknown as typeof IntegrationsService) : IntegrationsService);
|
||||
const hookSvc = (demo: boolean) =>
|
||||
@@ -48,7 +59,7 @@ const statusVariant = (s?: string) =>
|
||||
? "warning"
|
||||
: "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) {
|
||||
const [tab, setTab] = createSignal<Tab>("integrations");
|
||||
@@ -59,8 +70,23 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
const [analyticsSite, setAnalyticsSite] = createSignal<Record<string, string>>({});
|
||||
const [expandedIntegration, setExpandedIntegration] = 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 [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 [webhookForm, setWebhookForm] = createSignal({ url: "", secret: "", events: [] as string[], enabled: true });
|
||||
const [markerForm, setMarkerForm] = createSignal({ version: "", ref: "", environment: "", note: "" });
|
||||
@@ -75,12 +101,20 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [int, hooks] = await Promise.all([
|
||||
const [int, hooks, rules, inbound, jobs, fns] = await Promise.all([
|
||||
intSvc(props.demoMode).listIntegrations({ 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 ?? []);
|
||||
setWebhooks(hooks.items ?? []);
|
||||
setAlertRules(rules.items ?? []);
|
||||
setInboundHooks(inbound.items ?? []);
|
||||
setScheduledJobs(jobs.items ?? []);
|
||||
setFunctions(fns.items ?? []);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
@@ -205,6 +239,64 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
"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) =>
|
||||
run(
|
||||
`test-hook-${id}`,
|
||||
@@ -506,6 +598,158 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</Show>
|
||||
</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}>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
@@ -555,7 +799,7 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</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">
|
||||
<Select label="Type" value="rybbit" disabled options={[{ value: "rybbit", label: "Rybbit — web analytics" }]} />
|
||||
<Input
|
||||
@@ -596,7 +840,7 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</form>
|
||||
</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">
|
||||
<Input
|
||||
label="Endpoint URL"
|
||||
@@ -639,6 +883,119 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { For, Show, createMemo, createSignal } from "solid-js";
|
||||
import type {
|
||||
OrganizationInvitation,
|
||||
OrganizationMember,
|
||||
ProjectMember,
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
import {
|
||||
EmailService,
|
||||
type EmailLogEntry,
|
||||
type OrganizationInvitation,
|
||||
type OrganizationMember,
|
||||
type ProjectMember,
|
||||
} from "@primora/api-client";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
@@ -22,6 +24,8 @@ interface InvitationInput {
|
||||
}
|
||||
|
||||
interface MembersPageProps {
|
||||
projectID?: string;
|
||||
demoMode?: boolean;
|
||||
organizationMembers?: OrganizationMember[];
|
||||
organizationInvitations?: OrganizationInvitation[];
|
||||
projectMembers?: ProjectMember[];
|
||||
@@ -80,6 +84,15 @@ export function MembersPage(props: MembersPageProps) {
|
||||
const [tab, setTab] = createSignal<"org" | "project">("org");
|
||||
const [inviteOpen, setInviteOpen] = createSignal(false);
|
||||
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 q = query().toLowerCase();
|
||||
@@ -368,6 +381,49 @@ export function MembersPage(props: MembersPageProps) {
|
||||
</div>
|
||||
</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 */}
|
||||
<Modal
|
||||
open={inviteOpen()}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { demoService } from "../lib/demo-mode";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Select } from "../components/Input";
|
||||
import { errorMessage } from "../lib/api";
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconRefresh,
|
||||
@@ -25,7 +26,7 @@ interface TelemetryPageProps {
|
||||
|
||||
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) =>
|
||||
(demo ? (demoService as unknown as typeof TelemetryService) : TelemetryService);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Input, Select, Textarea } from "../components/Input";
|
||||
import { IconPlus, IconTrash, IconCopy, IconRefresh, IconKey } from "../components/Icons";
|
||||
import { errorMessage } from "../lib/api";
|
||||
|
||||
interface VaultPageProps {
|
||||
projectID?: string;
|
||||
@@ -13,7 +14,7 @@ interface VaultPageProps {
|
||||
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 DEMO_SECRETS: ProjectSecret[] = [
|
||||
@@ -376,7 +377,7 @@ export function VaultPage(props: VaultPageProps) {
|
||||
</div>
|
||||
</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">
|
||||
<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 />
|
||||
@@ -389,7 +390,7 @@ export function VaultPage(props: VaultPageProps) {
|
||||
</form>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -8,6 +8,7 @@ export { DatabasesPage } from "./DatabasesPage";
|
||||
export { TelemetryPage } from "./TelemetryPage";
|
||||
export { IntegrationsPage } from "./IntegrationsPage";
|
||||
export { VaultPage } from "./VaultPage";
|
||||
export { FunctionsPage } from "./FunctionsPage";
|
||||
export { AutomationPage } from "./AutomationPage";
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
export { AuditPage } from "./AuditPage";
|
||||
|
||||
@@ -38,15 +38,6 @@ export default defineConfig({
|
||||
urlPattern: /\/(api|auth|mailpit)(\/|$)/,
|
||||
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
@@ -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
|
||||
metadata (names/urls/notes only); requires `primora vault unlock` on the host.
|
||||
- `primora_vault_exec` — runs `primora` commands with a vault secret injected
|
||||
into the child's env; `vault`/`secrets`/`inject`/`agent`/`login`/`use` and
|
||||
`keys:create` are denied so no response can carry a credential.
|
||||
into the child's env. A strict allow-list (read-mostly commands like
|
||||
`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
|
||||
write-only storage. No reveal or delete: agents consume secrets through
|
||||
`secret://NAME` references in job payloads, resolved server-side at delivery.
|
||||
@@ -49,6 +49,8 @@ async function mintSessionToken(cfg: McpConfig): Promise<string> {
|
||||
|
||||
export function configureClient(cfg: McpConfig): void {
|
||||
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
|
||||
// encodeURI leaves ?#& intact in path params — smuggles query segments.
|
||||
OpenAPI.ENCODE_PATH = encodeURIComponent;
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
|
||||
return;
|
||||
|
||||
+164
-56
@@ -22,7 +22,16 @@ import {
|
||||
import { configureClient, loadConfig, type McpConfig } from "./config.js";
|
||||
|
||||
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))/;
|
||||
|
||||
@@ -41,14 +50,62 @@ function fail(error: unknown) {
|
||||
return { content: [{ type: "text" as const, text: `error: ${message}` }], isError: true };
|
||||
}
|
||||
|
||||
function projectId(override?: string): string {
|
||||
const id = override ?? cfg.projectId;
|
||||
async function projectId(override?: string): Promise<string> {
|
||||
// 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) {
|
||||
throw new Error("no project selected — pass projectId, run `primora use`, or set PRIMORA_PROJECT");
|
||||
}
|
||||
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> {
|
||||
const { items } = await StorageService.listBuckets({ projectId: project });
|
||||
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" },
|
||||
async () => {
|
||||
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());
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -110,9 +172,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ organizationId }) => {
|
||||
try {
|
||||
const org = organizationId ?? cfg.organizationId;
|
||||
if (!org) throw new Error("no organization selected — pass organizationId or run `primora use`");
|
||||
return ok(await ProjectsService.listProjects({ organizationId: org }));
|
||||
return ok(await ProjectsService.listProjects({ organizationId: await orgId(organizationId) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -132,11 +192,9 @@ server.registerTool(
|
||||
},
|
||||
async ({ name, slug, description, organizationId }) => {
|
||||
try {
|
||||
const org = organizationId ?? cfg.organizationId;
|
||||
if (!org) throw new Error("no organization selected — pass organizationId or run `primora use`");
|
||||
return ok(
|
||||
await ProjectsService.createProject({
|
||||
organizationId: org,
|
||||
organizationId: await orgId(organizationId),
|
||||
requestBody: { name, slug: slug ?? slugify(name), description },
|
||||
}),
|
||||
);
|
||||
@@ -154,7 +212,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await StorageService.listBuckets({ projectId: projectId(p) }));
|
||||
return ok(await StorageService.listBuckets({ projectId: await projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -176,7 +234,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await StorageService.createBucket({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
requestBody: {
|
||||
name,
|
||||
slug: slug ?? slugify(name),
|
||||
@@ -208,7 +266,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ bucket, projectId: p, q, limit, offset }) => {
|
||||
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 }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -231,10 +289,16 @@ server.registerTool(
|
||||
},
|
||||
async ({ bucket, key, content, contentBase64, contentType, projectId: p }) => {
|
||||
try {
|
||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
||||
const bucketId = await bucketIdFor(await projectId(p), bucket);
|
||||
let blob: Blob;
|
||||
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",
|
||||
});
|
||||
} else if (content !== undefined) {
|
||||
@@ -266,7 +330,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ bucket, key, projectId: p }) => {
|
||||
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 type = res.headers.get("content-type") ?? "application/octet-stream";
|
||||
if (TEXT_TYPES.test(type)) {
|
||||
@@ -292,7 +356,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ bucket, key, projectId: p }) => {
|
||||
try {
|
||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
||||
const bucketId = await bucketIdFor(await projectId(p), bucket);
|
||||
await StorageService.deleteBucketObject({ bucketId, objectKey: key });
|
||||
return ok({ deleted: key });
|
||||
} catch (e) {
|
||||
@@ -309,7 +373,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await ProjectsService.listApiKeys({ projectId: projectId(p) }));
|
||||
return ok(await ProjectsService.listApiKeys({ projectId: await projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -324,7 +388,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ name, projectId: p }) => {
|
||||
try {
|
||||
return ok(await ProjectsService.createApiKey({ projectId: projectId(p), requestBody: { name } }));
|
||||
return ok(await ProjectsService.createApiKey({ projectId: await projectId(p), requestBody: { name } }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -339,7 +403,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ apiKeyId, projectId: p }) => {
|
||||
try {
|
||||
await ProjectsService.revokeApiKey({ projectId: projectId(p), apiKeyId });
|
||||
await ProjectsService.revokeApiKey({ projectId: await projectId(p), apiKeyId });
|
||||
return ok({ revoked: apiKeyId });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -362,7 +426,7 @@ server.registerTool(
|
||||
async ({ projectId: p, q, action, limit, offset }) => {
|
||||
try {
|
||||
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) {
|
||||
return fail(e);
|
||||
@@ -383,7 +447,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p, days }) => {
|
||||
try {
|
||||
return ok(await TelemetryService.listTelemetryIssues({ projectId: projectId(p), days }));
|
||||
return ok(await TelemetryService.listTelemetryIssues({ projectId: await projectId(p), days }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -407,7 +471,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await TelemetryService.listTelemetryEvents({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
type,
|
||||
component,
|
||||
fingerprint,
|
||||
@@ -432,7 +496,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p, window }) => {
|
||||
try {
|
||||
return ok(await TelemetryService.getTelemetryStats({ projectId: projectId(p), window }));
|
||||
return ok(await TelemetryService.getTelemetryStats({ projectId: await projectId(p), window }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -452,7 +516,7 @@ server.registerTool(
|
||||
async ({ projectId: p, name, window }) => {
|
||||
try {
|
||||
return ok(
|
||||
await TelemetryService.getTelemetryMetricSeries({ projectId: projectId(p), name, window }),
|
||||
await TelemetryService.getTelemetryMetricSeries({ projectId: await projectId(p), name, window }),
|
||||
);
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -474,7 +538,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await IntegrationsService.listIntegrations({ projectId: projectId(p) }));
|
||||
return ok(await IntegrationsService.listIntegrations({ projectId: await projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -498,7 +562,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await IntegrationsService.createIntegration({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
requestBody: {
|
||||
name,
|
||||
type: CreateIntegrationRequest.type.RYBBIT,
|
||||
@@ -522,7 +586,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ integrationId, projectId: p }) => {
|
||||
try {
|
||||
await IntegrationsService.deleteIntegration({ projectId: projectId(p), integrationId });
|
||||
await IntegrationsService.deleteIntegration({ projectId: await projectId(p), integrationId });
|
||||
return ok({ deleted: integrationId });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -538,7 +602,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ integrationId, projectId: p }) => {
|
||||
try {
|
||||
return ok(await IntegrationsService.testIntegration({ projectId: projectId(p), integrationId }));
|
||||
return ok(await IntegrationsService.testIntegration({ projectId: await projectId(p), integrationId }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -561,7 +625,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await IntegrationsService.getIntegrationAnalytics({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
integrationId,
|
||||
site,
|
||||
days,
|
||||
@@ -603,7 +667,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await WebhooksService.listWebhooks({ projectId: projectId(p) }));
|
||||
return ok(await WebhooksService.listWebhooks({ projectId: await projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -627,7 +691,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await WebhooksService.createWebhook({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
requestBody: { url, secret, events, enabled },
|
||||
}),
|
||||
);
|
||||
@@ -655,7 +719,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await WebhooksService.updateWebhook({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
webhookId,
|
||||
requestBody: { url, secret, events, enabled },
|
||||
}),
|
||||
@@ -674,7 +738,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ webhookId, projectId: p }) => {
|
||||
try {
|
||||
await WebhooksService.deleteWebhook({ projectId: projectId(p), webhookId });
|
||||
await WebhooksService.deleteWebhook({ projectId: await projectId(p), webhookId });
|
||||
return ok({ deleted: webhookId });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -696,7 +760,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await WebhooksService.listWebhookDeliveries({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
webhookId,
|
||||
limit,
|
||||
}),
|
||||
@@ -715,7 +779,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ webhookId, projectId: p }) => {
|
||||
try {
|
||||
return ok(await WebhooksService.testWebhook({ projectId: projectId(p), webhookId }));
|
||||
return ok(await WebhooksService.testWebhook({ projectId: await projectId(p), webhookId }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -740,7 +804,7 @@ server.registerTool(
|
||||
if (!version && !ref) throw new Error("pass version or ref");
|
||||
return ok(
|
||||
await WebhooksService.createDeployMarker({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
requestBody: { version, ref, environment, note },
|
||||
}),
|
||||
);
|
||||
@@ -764,7 +828,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await AutomationService.listScheduledJobs({ projectId: projectId(p) }));
|
||||
return ok(await AutomationService.listScheduledJobs({ projectId: await projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -792,7 +856,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await AutomationService.createScheduledJob({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
requestBody: { name, schedule, url, secret, payload, enabled },
|
||||
}),
|
||||
);
|
||||
@@ -821,7 +885,7 @@ server.registerTool(
|
||||
try {
|
||||
return ok(
|
||||
await AutomationService.updateScheduledJob({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
jobId,
|
||||
requestBody: { name, schedule, url, secret, payload, enabled },
|
||||
}),
|
||||
@@ -840,7 +904,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ jobId, projectId: p }) => {
|
||||
try {
|
||||
await AutomationService.deleteScheduledJob({ projectId: projectId(p), jobId });
|
||||
await AutomationService.deleteScheduledJob({ projectId: await projectId(p), jobId });
|
||||
return ok({ deleted: jobId });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -861,7 +925,7 @@ server.registerTool(
|
||||
async ({ jobId, limit, projectId: p }) => {
|
||||
try {
|
||||
return ok(
|
||||
await AutomationService.listScheduledJobRuns({ projectId: projectId(p), jobId, limit }),
|
||||
await AutomationService.listScheduledJobRuns({ projectId: await projectId(p), jobId, limit }),
|
||||
);
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -877,7 +941,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ jobId, projectId: p }) => {
|
||||
try {
|
||||
return ok(await AutomationService.runScheduledJob({ projectId: projectId(p), jobId }));
|
||||
return ok(await AutomationService.runScheduledJob({ projectId: await projectId(p), jobId }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -939,20 +1003,65 @@ server.registerTool(
|
||||
},
|
||||
);
|
||||
|
||||
// Commands that would move a secret into the response are denied outright —
|
||||
// the CLI is the broker; the model sees results, not credentials.
|
||||
const EXEC_DENY_FIRST = new Set(["vault", "secrets", "inject", "agent", "login", "logout", "use"]);
|
||||
const EXEC_DENY_PAIR = new Set(["keys:create"]);
|
||||
// vault_exec allow-list — read/non-destructive commands an agent may run
|
||||
// under an injected secret. Deny-lists lose (a colon-form `secrets:get`
|
||||
// already bypassed a pair-match once); only these verbs execute.
|
||||
// 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(
|
||||
"primora_vault_exec",
|
||||
{
|
||||
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: {
|
||||
command: z
|
||||
.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
|
||||
.string()
|
||||
.optional()
|
||||
@@ -962,17 +1071,16 @@ server.registerTool(
|
||||
async ({ command, secret }) => {
|
||||
try {
|
||||
if (!command.length) throw new Error("empty command");
|
||||
const first = command[0];
|
||||
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 { args } = execAllowed(command);
|
||||
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]);
|
||||
if (got.code !== 0) {
|
||||
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 });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
@@ -992,7 +1100,7 @@ server.registerTool(
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await SecretsService.listProjectSecrets({ projectId: projectId(p) }));
|
||||
return ok(await SecretsService.listProjectSecrets({ projectId: await projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
@@ -1015,7 +1123,7 @@ server.registerTool(
|
||||
async ({ name, value, url, notes, projectId: p }) => {
|
||||
try {
|
||||
const res = await SecretsService.setProjectSecret({
|
||||
projectId: projectId(p),
|
||||
projectId: await projectId(p),
|
||||
name,
|
||||
requestBody: { value, url, notes },
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
ports:
|
||||
- "${MAILPIT_HTTP_PORT}:8025"
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
+6
-2
@@ -48,6 +48,10 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- 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:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health/liveness || exit 1"]
|
||||
interval: 15s
|
||||
@@ -100,7 +104,7 @@ services:
|
||||
auth:
|
||||
condition: service_started
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -120,7 +124,7 @@ services:
|
||||
volumes:
|
||||
- ./infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost/nginx-health >/dev/null 2>&1 || exit 1"]
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/nginx-health >/dev/null 2>&1 || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
Generated
+21
-1
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@primora/cli",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.8.1",
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
@@ -78,6 +78,8 @@
|
||||
"name": "@primora/frontend",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.3.0",
|
||||
"@fontsource/geist-mono": "^5.3.0",
|
||||
"@primora/api-client": "0.5.0",
|
||||
"@primora/shared-types": "0.5.0",
|
||||
"better-auth": "^1.5.6",
|
||||
@@ -2341,6 +2343,24 @@
|
||||
"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": {
|
||||
"version": "1.19.11",
|
||||
"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
Reference in new issue
Block a user