mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51: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
+9982
-217
No files matched your search
@@ -50,6 +50,31 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1
|
|||||||
BACKEND_INTERNAL_URL=http://backend:8080
|
BACKEND_INTERNAL_URL=http://backend:8080
|
||||||
BACKEND_STORAGE_ROOT=/data/storage
|
BACKEND_STORAGE_ROOT=/data/storage
|
||||||
|
|
||||||
|
# Object storage driver: "local" (default, uses BACKEND_STORAGE_ROOT) or "s3".
|
||||||
|
# For s3, point S3_ENDPOINT at any S3-compatible API (AWS, MinIO, Garage, R2).
|
||||||
|
BACKEND_STORAGE_DRIVER=local
|
||||||
|
#S3_ENDPOINT=http://minio:9000
|
||||||
|
#S3_REGION=us-east-1
|
||||||
|
#S3_BUCKET=primora-objects
|
||||||
|
#S3_ACCESS_KEY_ID=
|
||||||
|
#S3_SECRET_ACCESS_KEY=
|
||||||
|
#S3_PREFIX=
|
||||||
|
#S3_PATH_STYLE=true
|
||||||
|
# Client-facing S3 endpoint for presigned URLs — set when S3_ENDPOINT is an
|
||||||
|
# internal address (e.g. http://minio:9000 inside compose) that browsers
|
||||||
|
# can't reach. Presigned URLs are valid only at the host they were minted for.
|
||||||
|
#S3_PUBLIC_ENDPOINT=https://objects.example.com
|
||||||
|
|
||||||
|
# Functions execution. "exec" runs the runtime binary (bun/deno) directly on
|
||||||
|
# the backend host — simple, but function code inherits backend privileges.
|
||||||
|
# "docker" runs each invoke in an ephemeral locked-down container (no
|
||||||
|
# network, read-only fs, 128 MiB mem, non-root) and requires the Docker
|
||||||
|
# socket mounted into the backend plus the runtime images pulled.
|
||||||
|
#FUNCTIONS_DRIVER=exec
|
||||||
|
#FUNCTIONS_RUNTIME=auto
|
||||||
|
#FUNCTIONS_TIMEOUT_SECONDS=30
|
||||||
|
#FUNCTIONS_MAX_OUTPUT_BYTES=65536
|
||||||
|
|
||||||
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
|
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
|
||||||
# values win over these env vars; deleting the in-app value reverts here.
|
# values win over these env vars; deleting the in-app value reverts here.
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
- name: Build Tauri bundles
|
- name: Build Tauri bundles
|
||||||
|
# Updater artifacts are minisign-signed; without the key the build
|
||||||
|
# still succeeds but emits no .sig files (updater can't verify them).
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
run: npm run bundle --workspace @primora/desktop
|
run: npm run bundle --workspace @primora/desktop
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -155,8 +160,11 @@ jobs:
|
|||||||
apps/desktop/src-tauri/target/release/bundle/**/*.deb
|
apps/desktop/src-tauri/target/release/bundle/**/*.deb
|
||||||
apps/desktop/src-tauri/target/release/bundle/**/*.rpm
|
apps/desktop/src-tauri/target/release/bundle/**/*.rpm
|
||||||
apps/desktop/src-tauri/target/release/bundle/**/*.dmg
|
apps/desktop/src-tauri/target/release/bundle/**/*.dmg
|
||||||
|
apps/desktop/src-tauri/target/release/bundle/**/*.app.tar.gz
|
||||||
apps/desktop/src-tauri/target/release/bundle/**/*.msi
|
apps/desktop/src-tauri/target/release/bundle/**/*.msi
|
||||||
apps/desktop/src-tauri/target/release/bundle/**/*.exe
|
apps/desktop/src-tauri/target/release/bundle/**/*.exe
|
||||||
|
apps/desktop/src-tauri/target/release/bundle/**/*.nsis.zip
|
||||||
|
apps/desktop/src-tauri/target/release/bundle/**/*.sig
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
npm-packages:
|
npm-packages:
|
||||||
@@ -225,6 +233,48 @@ jobs:
|
|||||||
flag
|
flag
|
||||||
' CHANGELOG.md > notes.md
|
' CHANGELOG.md > notes.md
|
||||||
[ -s notes.md ] || echo "Release ${VERSION}" > notes.md
|
[ -s notes.md ] || echo "Release ${VERSION}" > notes.md
|
||||||
|
# Tauri updater manifest: one platform entry per signed updater
|
||||||
|
# artifact (AppImage.sig / app.tar.gz.sig / nsis.zip.sig). Missing
|
||||||
|
# sigs mean TAURI_SIGNING_PRIVATE_KEY isn't set — then there is no
|
||||||
|
# manifest and no update, which is correct (never ship unsigned).
|
||||||
|
- name: Build updater manifest (latest.json)
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, os, glob
|
||||||
|
base = "https://github.com/Dvorinka/Primora/releases/download/%s" % os.environ["VERSION"]
|
||||||
|
platforms = {}
|
||||||
|
for sig in glob.glob("dist-release/**/*.sig", recursive=True):
|
||||||
|
asset = sig[:-4]
|
||||||
|
name = os.path.basename(asset)
|
||||||
|
if name.endswith(".AppImage"):
|
||||||
|
plat = "linux-x86_64"
|
||||||
|
elif name.endswith(".app.tar.gz"):
|
||||||
|
plat = "darwin-aarch64" if "aarch64" in name else "darwin-x86_64"
|
||||||
|
if "aarch64" not in name and "x86_64" not in name:
|
||||||
|
plat = "darwin-aarch64" # macos-latest is arm64
|
||||||
|
elif name.endswith(".nsis.zip"):
|
||||||
|
plat = "windows-x86_64"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
platforms[plat] = {
|
||||||
|
"signature": open(sig).read().strip(),
|
||||||
|
"url": f"{base}/{name}",
|
||||||
|
}
|
||||||
|
manifest = {
|
||||||
|
"version": os.environ["VERSION"],
|
||||||
|
"notes": "See the release notes.",
|
||||||
|
"pub_date": __import__("datetime").datetime.now(
|
||||||
|
__import__("datetime").timezone.utc
|
||||||
|
).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"platforms": platforms,
|
||||||
|
}
|
||||||
|
if platforms:
|
||||||
|
with open("dist-release/latest.json", "w") as f:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
print("latest.json:", ", ".join(platforms))
|
||||||
|
else:
|
||||||
|
print("no signed updater artifacts — skipping latest.json")
|
||||||
|
PY
|
||||||
- name: Create release
|
- name: Create release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -234,6 +284,54 @@ jobs:
|
|||||||
mapfile -t files < <(find dist-release -type f)
|
mapfile -t files < <(find dist-release -type f)
|
||||||
[ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; }
|
[ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; }
|
||||||
gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}"
|
gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}"
|
||||||
|
# The manifest must be fetchable at /releases/latest/download/latest.json
|
||||||
|
if [ -f dist-release/latest.json ]; then
|
||||||
|
gh release upload "$VERSION" dist-release/latest.json --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Software bill of materials — CycloneDX JSON per published image plus
|
||||||
|
# one for the source tree (npm + Go modules). Attached to the release
|
||||||
|
# next to the binaries; downstream consumers can verify provenance.
|
||||||
|
sbom:
|
||||||
|
name: SBOMs
|
||||||
|
needs: [verify, release]
|
||||||
|
if: needs.verify.outputs.needed == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: read
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.verify.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: anchore/sbom-action@v0
|
||||||
|
with:
|
||||||
|
image: ghcr.io/${{ github.repository_owner }}/primora-backend:${{ needs.verify.outputs.version }}
|
||||||
|
format: cyclonedx-json
|
||||||
|
output-file: dist-release/sbom-backend.cdx.json
|
||||||
|
- uses: anchore/sbom-action@v0
|
||||||
|
with:
|
||||||
|
image: ghcr.io/${{ github.repository_owner }}/primora-auth:${{ needs.verify.outputs.version }}
|
||||||
|
format: cyclonedx-json
|
||||||
|
output-file: dist-release/sbom-auth.cdx.json
|
||||||
|
- uses: anchore/sbom-action@v0
|
||||||
|
with:
|
||||||
|
image: ghcr.io/${{ github.repository_owner }}/primora-frontend:${{ needs.verify.outputs.version }}
|
||||||
|
format: cyclonedx-json
|
||||||
|
output-file: dist-release/sbom-frontend.cdx.json
|
||||||
|
- uses: anchore/sbom-action@v0
|
||||||
|
with:
|
||||||
|
path: .
|
||||||
|
format: cyclonedx-json
|
||||||
|
output-file: dist-release/sbom-source.cdx.json
|
||||||
|
- name: Attach SBOMs to the release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: gh release upload "$VERSION" dist-release/sbom-*.cdx.json --clobber
|
||||||
|
|
||||||
npm-publish:
|
npm-publish:
|
||||||
name: Publish to npm
|
name: Publish to npm
|
||||||
|
|||||||
+34
-1
@@ -11,14 +11,47 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
|
|||||||
- **`primora agent` credential broker** — wraps a child process with dummy tokens and a loopback base URL; the proxy attaches the real secret on the wire per grant (TTL, cap 3600 s). Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`, `github`, `npm`; `--upstream name=https://…` covers arbitrary Bearer APIs. Base-URL broker, not TLS MITM — tools must honor a base-URL override.
|
- **`primora agent` credential broker** — wraps a child process with dummy tokens and a loopback base URL; the proxy attaches the real secret on the wire per grant (TTL, cap 3600 s). Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`, `github`, `npm`; `--upstream name=https://…` covers arbitrary Bearer APIs. Base-URL broker, not TLS MITM — tools must honor a base-URL override.
|
||||||
- **`primora inject`** — runs a child process with vault secrets in its environment (`--all`, or `--env-file` templates containing `primora://NAME` refs). Nothing on disk, nothing in argv.
|
- **`primora inject`** — runs a child process with vault secrets in its environment (`--all`, or `--env-file` templates containing `primora://NAME` refs). Nothing on disk, nothing in argv.
|
||||||
- **`primora stack:*` operator group** — `up`, `down`, `status`, `logs [service]`, `pull`, `backup` wrap `docker compose` in `--dir` (default cwd). `stack up --vault` materializes `.env` from the vault for the duration of the run (refuses to clobber an existing `.env` without `--force`, which swaps and restores it). `stack backup` writes a `pg_dump -Fc` of the platform database to `./backups`.
|
- **`primora stack:*` operator group** — `up`, `down`, `status`, `logs [service]`, `pull`, `backup` wrap `docker compose` in `--dir` (default cwd). `stack up --vault` materializes `.env` from the vault for the duration of the run (refuses to clobber an existing `.env` without `--force`, which swaps and restores it). `stack backup` writes a `pg_dump -Fc` of the platform database to `./backups`.
|
||||||
- **Vault MCP tools** (`@primora/mcp`) — `primora_vault_status`, `primora_vault_list` (metadata only — values are never returned), and `primora_vault_exec` which runs `primora` commands with a secret injected into the child environment; `vault`/`secrets`/`inject`/`agent`/`login`/`logout`/`use` and `keys:create` are denied so no response can carry a credential.
|
- **Vault MCP tools** (`@primora/mcp`) — `primora_vault_status`, `primora_vault_list` (metadata only — values are never returned), and `primora_vault_exec` which runs `primora` commands with a secret injected into the child environment; `vault`/`secrets`/`inject`/`agent`/`login`/`logout`/`use` and `keys:create` are denied so no response can carry a credential. (Hardened to a strict allow-list — see Security.)
|
||||||
- **Secrets Vault window in the desktop app** (`@primora/desktop`) — tray "Secrets Vault…" opens a dedicated window (`ui/vault.html`) covering init, timed unlock/lock with live countdown, secret list with copy-to-clipboard (values never displayed), add/delete, and `.env` import. IPC shells out to the `primora` binary (`PRIMORA_CLI` overrides), so desktop and terminal share one vault file and one crypto implementation; a missing CLI shows install guidance. Also reachable from the connect screen.
|
- **Secrets Vault window in the desktop app** (`@primora/desktop`) — tray "Secrets Vault…" opens a dedicated window (`ui/vault.html`) covering init, timed unlock/lock with live countdown, secret list with copy-to-clipboard (values never displayed), add/delete, and `.env` import. IPC shells out to the `primora` binary (`PRIMORA_CLI` overrides), so desktop and terminal share one vault file and one crypto implementation; a missing CLI shows install guidance. Also reachable from the connect screen.
|
||||||
- **Project vault — server-side secrets** (`core.project_secrets`, migration 00011) — per-project secrets encrypted AES-256-GCM at rest via the existing encryptor. API: `GET/PUT/DELETE /projects/:projectID/secrets[/:name]` plus `POST …/reveal`. Lists return metadata only (name, url, notes, timestamps); reveal decrypts explicitly and is audit-logged (`secret.set`/`secret.revealed`/`secret.deleted`). Scheduled-job payloads reference secrets as `secret://NAME`, resolved to plaintext at delivery — refs are stored, values are not; unresolved or unconfigured refs fail the run loudly.
|
- **Project vault — server-side secrets** (`core.project_secrets`, migration 00011) — per-project secrets encrypted AES-256-GCM at rest via the existing encryptor. API: `GET/PUT/DELETE /projects/:projectID/secrets[/:name]` plus `POST …/reveal`. Lists return metadata only (name, url, notes, timestamps); reveal decrypts explicitly and is audit-logged (`secret.set`/`secret.revealed`/`secret.deleted`). Scheduled-job payloads reference secrets as `secret://NAME`, resolved to plaintext at delivery — refs are stored, values are not; unresolved or unconfigured refs fail the run loudly.
|
||||||
- **Vault page in the dashboard** — project secrets table with create/update, `.env` import, delete, and copy-to-clipboard reveal; plus a "This machine" card when running inside the desktop shell that drives the local vault through the same Tauri IPC commands as the vault window.
|
- **Vault page in the dashboard** — project secrets table with create/update, `.env` import, delete, and copy-to-clipboard reveal; plus a "This machine" card when running inside the desktop shell that drives the local vault through the same Tauri IPC commands as the vault window.
|
||||||
- **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values.
|
- **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values.
|
||||||
- **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext.
|
- **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext.
|
||||||
- **Realtime channel subscriptions** (`@primora/client`) — `createRealtimeClient({ endpoint, key, projectId })` opens the project SSE stream (`/realtime/stream`) via fetch + ReadableStream (EventSource can't send `X-API-Key`). `channel("documents").on("created", cb)` subscribes by event namespace (`document`/`object`/`issue`/`deploy`, `"*"` for all); auto-reconnects with capped exponential backoff; `onStateChange` reports `connecting|open|reconnecting|closed`.
|
- **Realtime channel subscriptions** (`@primora/client`) — `createRealtimeClient({ endpoint, key, projectId })` opens the project SSE stream (`/realtime/stream`) via fetch + ReadableStream (EventSource can't send `X-API-Key`). `channel("documents").on("created", cb)` subscribes by event namespace (`document`/`object`/`issue`/`deploy`, `"*"` for all); auto-reconnects with capped exponential backoff; `onStateChange` reports `connecting|open|reconnecting|closed`.
|
||||||
|
- **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components.
|
||||||
|
- **Inbound webhooks** (`core.inbound_hooks`, migration 00013) — public ingest endpoints `POST /api/v1/hooks/<token>` (the token is the credential). `event` mode republishes the body as `inbound.received` domain events through the signed dispatcher + realtime; `job` mode triggers a scheduled-job run with the received body as its payload (`triggered_by: "hook"`). Optional `secret` requires `X-Primora-Signature` HMAC-SHA256 — the same scheme outbound deliveries use. Bodies capped at 256 KiB. Managed from Integrations → Webhooks.
|
||||||
- **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection <slug> --filter … --order …`.
|
- **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection <slug> --filter … --order …`.
|
||||||
|
- **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test.
|
||||||
|
- **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations.
|
||||||
|
- **S3-compatible object storage backend** — `BACKEND_STORAGE_DRIVER=s3` swaps the local disk store for any S3-compatible API (AWS, MinIO, Garage, R2) via a stdlib-only SigV4 implementation — no AWS SDK dependency. `S3_ENDPOINT`/`S3_REGION`/`S3_BUCKET`/credentials configure it; `S3_PATH_STYLE` (default on) covers MinIO/Garage, off for AWS virtual-hosted. The configured bucket is created on startup if missing; objects stream with computed SHA-256, `storage_path` records `s3://bucket/key`. Verified live end-to-end (bucket → put → get → copy → list → delete).
|
||||||
|
- **Functions** (`core.functions` + `core.function_runs`, migration 00015) — project-scoped JS/TS executed by a runtime binary on the backend host (`bun` or `deno`, per function). The JSON payload arrives on stdin and as `PRIMORA_PAYLOAD`; stdout/stderr are captured per run (capped at 64 KiB) with status, exit code and duration — `FUNCTIONS_RUNTIME`/`FUNCTIONS_TIMEOUT_SECONDS`/`FUNCTIONS_MAX_OUTPUT_BYTES` configure the runner. API: CRUD under `/projects/:id/functions`, `POST …/invoke` (502 + recorded run on failure), `GET …/runs`. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Code executes with backend privileges — the documented self-hosted trust model.
|
||||||
|
- **Presigned object URLs** — `POST /buckets/:bucketID/object-presigns` mints SigV4 query-signed `GET`/`PUT` URLs (60 s–1 h TTL) so clients transfer large objects directly against S3 without proxying through the backend. Read role for downloads (object must exist — no key probing), write role for uploads. `S3_PUBLIC_ENDPOINT` rewrites the client-facing host for internal `S3_ENDPOINT`s; signatures bind the host so URLs are valid only where they were minted. The local driver returns a clear error — a presigned URL needs a real object API.
|
||||||
|
- **Desktop auto-updater** — `tauri-plugin-updater` wired end to end: minisign keypair, `createUpdaterArtifacts`, `latest.json` manifest built from signed artifacts on every release, and a silent background check on the connect screen offering "Update & restart" (download → verify → install → relaunch). Unsigned artifacts can never install — missing signature means no manifest, not a bypass. CI signs via `TAURI_SIGNING_PRIVATE_KEY` secrets; OS-level signing (notarization/Authenticode) remains open.
|
||||||
|
- **Release SBOMs** — CycloneDX SBOM for each published GHCR image (backend/auth/frontend) plus one for the source tree, attached to every GitHub Release via `anchore/sbom-action`.
|
||||||
|
- **Sandboxed function driver** — `FUNCTIONS_DRIVER=docker` runs every invoke in an ephemeral locked-down container: no network, read-only rootfs, 128 MiB memory, 0.5 CPU, 64 pids, non-root, all capabilities dropped. Source is injected via base64 env (no bind mount — works on Docker Desktop and remote daemons); timeout force-removes the container so nothing lingers. Verified live: code runs, network is unreachable inside, and deadline kills land.
|
||||||
|
- **Client events + presence** — `POST /projects/:id/events` publishes client-authored events (`custom.*` namespace enforced — system types can't be spoofed) through the same fan-out as system events: realtime subscribers, matching webhooks, and `event_pattern` functions all fire. Realtime now tracks presence: `presence.update` {online} broadcasts on every subscribe/unsubscribe (stream-only — never reaches webhooks or functions), `GET …/realtime/presence` reads the count. Client SDK gains `publish()` and `presence()`; CLI gains `events:send`.
|
||||||
|
- **Function triggers** (migration 00016) — three new ways to run a function, all through the same executor and run log. Scheduled jobs accept `function_id` instead of `url` (run status mirrors the exit code); inbound hooks gain `mode: "function"` — the received body becomes the function's stdin payload, acked immediately with the run recorded async; and `event_pattern` on a function (`document.*`, `*`, or an exact type) fires it detached whenever a matching domain event publishes — `{event, project_id, occurred_at, data}` as payload. Runs record trigger source: `manual`/`schedule`/`hook`/`event`. Dashboard: function event-trigger field, job target picker (URL or function), and inbound-hook function mode.
|
||||||
|
- **`GET /api/v1/context`** — actor self-discovery: for API keys returns scopes, key prefix, and the resolved organization + project (id/slug/name). Fixes the API-key onboarding dead end: `primora login --api-key` now validates the key against the instance before saving and seeds org/project context automatically; `primora use`, `context`, and `whoami` work under API-key auth via this endpoint.
|
||||||
|
- **CLI `orgs:rm` / `projects:rm` / `buckets:rm`** — destructive deletes gated behind `--yes` in non-interactive mode, interactive confirm otherwise. `buckets:rm` resolves id/slug/name.
|
||||||
|
- **Webhook wildcard subscriptions** — `events` accepts `*`, `<namespace>.*`, and `custom.*` patterns, matched at dispatch time via SQL `LIKE` — the same semantics function `event_pattern` already used. Client-published `custom.*` events can now be webhooked.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **`primora_vault_exec` rewritten as an allow-list** — the deny-list was bypassed by colon-form commands (`["secrets:get","X"]` as a single token evaded the pair check), exposing every vault secret; `--remote` variants revealed project secrets; and the `secret` param was used verbatim as an env name, allowing `NODE_OPTIONS=--require <file>` host code execution. Now only a fixed read-mostly command set runs (`whoami`, `context`, `orgs:list`, `projects:list`, `buckets:list`, `objects:list`, `documents:list`, `jobs:list`, `jobs:runs`, `audit:list`, `keys:list`, `events:send`), path/file flags and `..` args are rejected, injected env names must match `^[A-Z_][A-Z0-9_]{0,63}$`, and loader-hook names (`NODE_*`, `LD_*`, `DYLD_*`, `PATH`, `BASH*`, `PYTHON*`, `GIT_*`, `SSL_CERT*`, `XDG_*`, …) are blocked. Verified: every prior bypass denied, allowed commands still inject.
|
||||||
|
- **SSRF hardening on webhook/job URLs** — cloud metadata endpoints (`169.254.169.254`, `fd00:ec2::254`) are excluded from the link-local/private allowance, and pure decimal/hex "bare hostnames" (`2130706433`, `0x7f000001` — non-canonical IP encodings of loopback) are no longer treated as Docker service names.
|
||||||
|
- **Path params now `encodeURIComponent`** in all generated-client consumers (CLI, MCP, dashboard) — `encodeURI` left `?#&` intact, smuggling query segments through `{name}` params (e.g. `secrets/a?reveal=1`).
|
||||||
|
- **Strict base64 in `primora_upload_object`** — `Buffer.from(b64)` silently drops invalid characters; `contentBase64` is now validated (whitespace-tolerant) so corrupt input can't silently store garbage bytes.
|
||||||
|
- **MCP no-creds startup** — a missing config/API key previously threw inside `configureClient`, killing the process mid-handshake (protocol hang, stack on stderr). Startup now degrades: `initialize`/`tools/list` answer normally and every tool call returns the actionable error.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Silent CLI dispatch** — unknown commands, a bare `primora`, and group names without a verb exited 0 with no output. Now: unknown input errors with exit 1 (bare groups list their verbs), bare `primora` prints help.
|
||||||
|
- **Arg-parser numeric coercion** — `cac`/`mri` coerced numeric-looking flag values (`--value "007"` → `7`, `--value ""` → `0`), corrupting secrets and keys. Parsed options are now re-read as raw strings from argv for every non-boolean flag; `--value 007` round-trips intact.
|
||||||
|
- **`pgx.ErrNoRows` → 404** in the shared error mapper — missing resources returned raw "no rows in result set" 500s across all handlers.
|
||||||
|
- **Structured error hygiene** — `pgconn.PgError` codes map to clean statuses (23505 → 409 "resource already exists", 23503 → 409, 22001/54000 → 400 "value too large", 22021 → 400) instead of leaking SQLSTATE/constraint names; validator failures report `field is required`/`field failed <tag> validation`; a new `services.InputError` type maps service-layer input failures to 400 (webhook URL/event-type errors were 500s). Presign on the local driver returns `presign_unsupported` 400.
|
||||||
|
- **`fetch failed` polish** — connection failures report the cause code and suggest `primora context` instead of bare undici errors.
|
||||||
|
- **`login --json`** — the flag now emits JSON on both the session and API-key paths.
|
||||||
|
- **Dashboard fonts vendored** — Geist/Geist Mono now ship via `@fontsource` inside the bundle instead of a Google Fonts `<link>` the nginx CSP blocked (`style-src 'self'`). Removes the runtime Google dependency, works airgapped, and eliminates the console CSP violation.
|
||||||
|
|
||||||
## [0.7.1] - 2026-09-23
|
## [0.7.1] - 2026-09-23
|
||||||
|
|
||||||
|
|||||||
+29
-5
@@ -41,7 +41,14 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
|||||||
| `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
|
| `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
|
||||||
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
|
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
|
||||||
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
|
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
|
||||||
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container | yes |
|
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container (local driver) | yes |
|
||||||
|
| `BACKEND_STORAGE_DRIVER` | `local` (default) or `s3` — S3-compatible object storage | no |
|
||||||
|
| `S3_ENDPOINT` / `S3_REGION` / `S3_BUCKET` | S3 API endpoint (empty endpoint → AWS `s3.<region>.amazonaws.com`), region, bucket | with `s3` |
|
||||||
|
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 credentials | with `s3` |
|
||||||
|
| `S3_PREFIX` / `S3_PATH_STYLE` | optional key prefix; path-style URLs (default `true`, needed by MinIO/Garage — set `false` for AWS virtual-hosted) | no |
|
||||||
|
| `S3_PUBLIC_ENDPOINT` | client-facing S3 endpoint used for presigned URLs when `S3_ENDPOINT` is internal-only (e.g. `http://minio:9000`); signatures bind the public host | no |
|
||||||
|
| `FUNCTIONS_DRIVER` | Function execution driver: `exec` (default — runtime binary on the backend host; code runs with backend privileges) or `docker` — each invoke runs in an ephemeral locked-down container (no network, read-only fs, 128 MiB, non-root). Docker mode needs `/var/run/docker.sock` mounted into the backend (see the commented volume in `docker-compose.yml`) and the runtime images pulled (`oven/bun`, `denoland/deno`). Source code is capped at 256 KiB under the docker driver | no |
|
||||||
|
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` / `FUNCTIONS_MAX_OUTPUT_BYTES` | Functions: runtime binary (`auto` resolves `bun`/`deno` — exec driver only; docker driver uses the matching official image), per-run timeout (default 30 s), captured-output cap (default 64 KiB) | no |
|
||||||
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
|
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
|
||||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
|
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
|
||||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
|
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
|
||||||
@@ -127,10 +134,27 @@ docker compose up -d # 3. rolling recreate; backend migrate
|
|||||||
|
|
||||||
## Storage constraint
|
## Storage constraint
|
||||||
|
|
||||||
Object storage is the **local filesystem** (`backend_storage` volume). There
|
Object storage defaults to the **local filesystem** (`backend_storage`
|
||||||
is no S3-compatible backend yet — the volume must be on durable, backed-up
|
volume) — durable, backed-up disk on a single host. For multi-node or
|
||||||
disk, and a single host serves all objects. Plan capacity accordingly;
|
external object stores set `BACKEND_STORAGE_DRIVER=s3` + the `S3_*`
|
||||||
multi-node deployments need an external object store first.
|
variables above (AWS, MinIO, Garage, R2 all work).
|
||||||
|
|
||||||
|
## Functions trust model
|
||||||
|
|
||||||
|
Two drivers, two trust levels:
|
||||||
|
|
||||||
|
- **`exec` (default)** — function code runs as a child process of the
|
||||||
|
backend: same host, same privileges, no sandbox. Treat every project
|
||||||
|
member who can edit a function as holding a shell on the backend host.
|
||||||
|
Appropriate for single-team self-hosted deployments.
|
||||||
|
- **`docker`** — each invoke runs in an ephemeral container: no network,
|
||||||
|
read-only rootfs, 128 MiB memory, half a CPU, 64 pids, non-root, all
|
||||||
|
capabilities dropped. Requires the Docker socket mounted into the
|
||||||
|
backend (commented volume in `docker-compose.yml`) — note the socket is
|
||||||
|
itself root-equivalent on the host, so this isolates *function code*,
|
||||||
|
not operators. Source code capped at 256 KiB. For hostile multi-tenant
|
||||||
|
workloads, a dedicated microVM runtime (Firecracker/gVisor) remains the
|
||||||
|
roadmap item.
|
||||||
|
|
||||||
## Observability
|
## Observability
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ The important ones:
|
|||||||
| `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app |
|
| `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app |
|
||||||
| `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only |
|
| `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only |
|
||||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app |
|
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app |
|
||||||
|
| `BACKEND_STORAGE_DRIVER` | `local` (default, `BACKEND_STORAGE_ROOT`) or `s3` — S3-compatible object storage |
|
||||||
|
| `S3_ENDPOINT` / `S3_BUCKET` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 backend config (with `S3_REGION`, `S3_PREFIX`, `S3_PATH_STYLE`) |
|
||||||
|
| `S3_PUBLIC_ENDPOINT` | Client-facing S3 host for presigned URLs when `S3_ENDPOINT` is internal-only |
|
||||||
|
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` | JS runtime for Functions — `auto` (default, resolves `bun`/`deno` from PATH) or a binary path; 30 s default timeout |
|
||||||
| `VITE_DEMO_MODE` | Enable the client-side demo workspace |
|
| `VITE_DEMO_MODE` | Enable the client-side demo workspace |
|
||||||
|
|
||||||
### Instance administration
|
### Instance administration
|
||||||
|
|||||||
@@ -48,8 +48,12 @@ Supabase, with no hosted tier - your data stays on your hardware.
|
|||||||
|
|
||||||
- **Organizations & projects** - workspaces with scoped org and project roles.
|
- **Organizations & projects** - workspaces with scoped org and project roles.
|
||||||
- **Auth** - email/password plus optional GitHub, Google, Discord, and Microsoft OAuth. JWTs verified by the API against JWKS.
|
- **Auth** - email/password plus optional GitHub, Google, Discord, and Microsoft OAuth. JWTs verified by the API against JWKS.
|
||||||
- **Storage** - S3-style buckets on your local filesystem, public or private.
|
- **Storage** - S3-style buckets on local disk or any S3-compatible backend (AWS, MinIO, Garage, R2), public or private. With `s3` the API mints presigned upload/download URLs so large objects skip the backend.
|
||||||
- **Collections** - schema-flexible JSON documents in Postgres JSONB.
|
- **Collections** - schema-flexible JSON documents in Postgres JSONB, with filter/order queries.
|
||||||
|
- **Functions** - project-scoped JS/TS run by bun/deno — on the host (`exec`) or in ephemeral locked-down containers (`docker` driver) — with per-run logs. Trigger them four ways: manual invoke, cron schedules (`function_id` on a job), inbound hooks (`mode: "function"`), or domain events (`event_pattern` like `document.*`).
|
||||||
|
- **Vault** - AES-256-GCM project secrets plus an Argon2id/XChaCha20 local vault in the CLI and desktop app.
|
||||||
|
- **Automation** - scheduled jobs, inbound webhooks, telemetry alert rules, and `secret://` refs resolved at delivery.
|
||||||
|
- **Realtime** - SSE event stream with a typed client SDK (`@primora/client`) — channel subscriptions, presence counts, and client-published `custom.*` events that fan out to webhooks and functions.
|
||||||
- **API keys** - `prm_<prefix>_<secret>` credentials, shown once.
|
- **API keys** - `prm_<prefix>_<secret>` credentials, shown once.
|
||||||
- **Audit log** - every mutating request recorded, with CSV/JSON export.
|
- **Audit log** - every mutating request recorded, with CSV/JSON export.
|
||||||
- **Demo mode** - fully client-side seeded workspace via `?demo=true`.
|
- **Demo mode** - fully client-side seeded workspace via `?demo=true`.
|
||||||
|
|||||||
+116
-23
@@ -123,8 +123,11 @@ Follow-ups:
|
|||||||
|
|
||||||
- Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run
|
- Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run
|
||||||
(host-verified on Linux only).
|
(host-verified on Linux only).
|
||||||
- Desktop bundle signing + auto-updater — unsigned artifacts today; add when
|
- ~~Desktop bundle signing + auto-updater~~ ✅ — `tauri-plugin-updater`
|
||||||
distribution matures.
|
wired: signed updater artifacts (`createUpdaterArtifacts`), minisign
|
||||||
|
keypair, `latest.json` manifest assembled on release, in-app
|
||||||
|
"Update & restart" on the connect screen. macOS/Windows OS-level code
|
||||||
|
signing (notarization/Authenticode) still open — needs paid certs.
|
||||||
|
|
||||||
## Phase 6 — Production hardening (v0.6.0) ✅
|
## Phase 6 — Production hardening (v0.6.0) ✅
|
||||||
|
|
||||||
@@ -185,8 +188,9 @@ release pipeline with images + binaries + desktop bundles.
|
|||||||
(auto-registers from ingest).
|
(auto-registers from ingest).
|
||||||
- ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres →
|
- ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres →
|
||||||
dragonfly transfer, key templating, TTL).
|
dragonfly transfer, key templating, TTL).
|
||||||
- ~~**External object storage**~~ ✅ — constraint documented in
|
- ~~**External object storage**~~ ✅ — `BACKEND_STORAGE_DRIVER=s3` shipped
|
||||||
DEPLOYMENT_GUIDE.md / PRODUCTION_READINESS.md; S3 backend deferred.
|
(Phase 10): any S3-compatible API + presigned URLs; local disk remains
|
||||||
|
the default.
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
@@ -205,8 +209,10 @@ release pipeline with images + binaries + desktop bundles.
|
|||||||
- ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via
|
- ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via
|
||||||
QEMU + buildx.
|
QEMU + buildx.
|
||||||
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle
|
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle
|
||||||
verification, signing + auto-updater.
|
verification; updater + minisign signing shipped (OS-level signing
|
||||||
- Optional: SBOM + provenance attestation on release images.
|
remains open).
|
||||||
|
- ~~Optional: SBOM~~ ✅ — CycloneDX SBOMs per published image + source
|
||||||
|
tree, attached to every release (`anchore/sbom-action`).
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
|
|
||||||
@@ -304,28 +310,115 @@ Channel subscriptions over the Phase 7 SSE stream, in `@primora/client`.
|
|||||||
with capped backoff, `onStateChange` lifecycle.
|
with capped backoff, `onStateChange` lifecycle.
|
||||||
- `channel("documents").on("created", cb)` — namespaces `document`, `object`,
|
- `channel("documents").on("created", cb)` — namespaces `document`, `object`,
|
||||||
`issue`, `deploy`, `"*"` for everything; trailing `s` optional.
|
`issue`, `deploy`, `"*"` for everything; trailing `s` optional.
|
||||||
- Presence and client-broadcast deliberately deferred — protocol question,
|
- **Presence** ✅ — server tracks realtime subscribers per project;
|
||||||
no consumer yet.
|
`presence.update` {online: n} broadcasts on every join/leave (realtime-only,
|
||||||
|
never fans out to webhooks/functions), `GET …/realtime/presence` reads the
|
||||||
|
count, `client.presence()` wraps it.
|
||||||
|
- **Client events** ✅ — `POST /projects/:id/events` publishes `custom.*`
|
||||||
|
types through the standard fan-out (realtime + webhooks + functions);
|
||||||
|
`client.publish("custom.x", data)` and `primora events:send` both reach it.
|
||||||
|
The `custom.*` prefix is enforced so clients can't spoof system events.
|
||||||
|
|
||||||
|
## Notifications — alert rules (unreleased)
|
||||||
|
|
||||||
|
Telemetry-driven alerts delivered through the existing webhook dispatcher.
|
||||||
|
|
||||||
|
- `core.alert_rules` (migration 00012) — `heartbeat_silence` and
|
||||||
|
`error_spike` kinds, per-component or project-wide scope, evaluated by a
|
||||||
|
60 s backend ticker.
|
||||||
|
- Transition-based firing: `alert.fired` once on breach, `alert.resolved`
|
||||||
|
on recovery — no notification storms. Both are webhook event types and
|
||||||
|
realtime events, so any existing subscriber receives them signed.
|
||||||
|
- CRUD via `/projects/:id/alerts`; Integrations page manages rules next to
|
||||||
|
webhooks and shows live `firing` state.
|
||||||
|
|
||||||
|
## Inbound webhooks (unreleased)
|
||||||
|
|
||||||
|
Project-scoped public ingest for external systems.
|
||||||
|
|
||||||
|
- `POST /api/v1/hooks/<token>` — token is the credential; optional
|
||||||
|
`X-Primora-Signature` HMAC-SHA256 verification when a secret is set.
|
||||||
|
- `event` mode republishes the body as `inbound.received` (webhooks +
|
||||||
|
realtime fan out for free); `job` mode enqueues a scheduled-job run with
|
||||||
|
the received body as payload (`triggered_by: "hook"`).
|
||||||
|
- 256 KiB body cap; `last_received_at` tracked; managed from Integrations.
|
||||||
|
|
||||||
|
## HA scheduler (unreleased)
|
||||||
|
|
||||||
|
Multi-replica safety for the job scheduler and alert evaluator.
|
||||||
|
|
||||||
|
- PostgreSQL advisory locks (session-scoped, separate keys per component)
|
||||||
|
on a dedicated pinned pool connection; only the leader scans for due work.
|
||||||
|
- Manual runs and hook-triggered runs work on any replica — the lock gates
|
||||||
|
only the scheduled scan, so leadership loss never blocks on-demand work.
|
||||||
|
- Released on shutdown; failover verified with a live two-replica test —
|
||||||
|
the survivor acquired leadership and fired the due job.
|
||||||
|
|
||||||
|
## Email surface (unreleased)
|
||||||
|
|
||||||
|
Reusable templates + an operational send log.
|
||||||
|
|
||||||
|
- `core.email_log` (migration 00014) — every send recorded: template,
|
||||||
|
recipient, subject, `sent`/`failed` status, error text.
|
||||||
|
- `Mailer` stays transport-only (Resend when configured, SMTP otherwise);
|
||||||
|
templates render subject + plaintext body. First template: `invitation`.
|
||||||
|
- `GET /projects/:id/emails` lists recent sends; Members page shows an
|
||||||
|
"Email log" card under pending invitations.
|
||||||
|
|
||||||
## Future phases — candidates
|
## Future phases — candidates
|
||||||
|
|
||||||
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
||||||
|
|
||||||
- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers.
|
- **Phase 9 — Functions** — ✅ shipped: `core.functions` +
|
||||||
Deno or Bun isolates in a sidecar; secrets via env, payload via stdin.
|
`core.function_runs`, bun/deno exec runner (stdin payload, env injection,
|
||||||
The job runner already supplies the scheduling half.
|
capped output, timeout), invoke API + dashboard page. Triggers shipped:
|
||||||
- **Phase 10 — Storage backends** — S3-compatible object storage behind
|
scheduled jobs (`function_id` on a job), inbound hooks (`mode:
|
||||||
the existing bucket abstraction (deferred from Phase 6), plus
|
"function"`), and domain events (`event_pattern`, e.g. `document.*`).
|
||||||
presigned-URL uploads for large files.
|
Runs record trigger source (`manual`/`schedule`/`hook`/`event`).
|
||||||
- **Notifications** — alert rules on telemetry (error-rate thresholds,
|
Isolation shipped: `FUNCTIONS_DRIVER=docker` runs every invoke in an
|
||||||
heartbeat silence) fanning out through the webhook dispatcher.
|
ephemeral locked-down container (no network, read-only fs, non-root).
|
||||||
- **Inbound webhooks** — project-scoped ingest endpoints that turn
|
Remaining candidate: Firecracker/gVisor microVMs for hostile
|
||||||
external events into domain events/jobs.
|
multi-tenant workloads — docker socket trust is documented.
|
||||||
- **HA mode** — leader-elected scheduler + distributed locks so two
|
- **Phase 10 — Storage backends** — ✅ shipped: `BACKEND_STORAGE_DRIVER=s3`
|
||||||
backend replicas don't double-fire jobs; Postgres advisory locks are
|
+ `S3_*` env vars, stdlib SigV4, verified against a real S3 server.
|
||||||
enough at this scale.
|
Presigned upload/download URLs shipped: `POST
|
||||||
- **Email surface** — transactional templates + send log in the
|
/buckets/:id/object-presigns` mints SigV4 query-signed GET/PUT URLs
|
||||||
dashboard, backed by the auth service's existing SMTP/Resend wiring.
|
(`S3_PUBLIC_ENDPOINT` rewrites the client-facing host); local driver
|
||||||
|
returns a clear error — a presigned URL needs a real object API behind it.
|
||||||
|
- **HA mode** — ✅ shipped: PostgreSQL advisory-lock leadership for the
|
||||||
|
scheduler and alert evaluator; failover verified with two replicas.
|
||||||
|
- **Email surface** — ✅ shipped: templated sends + `core.email_log` +
|
||||||
|
dashboard card; more templates land as new flows need them.
|
||||||
|
|
||||||
|
## Release hardening (unreleased)
|
||||||
|
|
||||||
|
- **Desktop auto-updater** — `tauri-plugin-updater`: signed updater
|
||||||
|
artifacts, `latest.json` manifest assembled per release, in-app
|
||||||
|
"Update & restart" on the connect screen. Unsigned artifacts can never
|
||||||
|
install — no signature means no manifest.
|
||||||
|
- **SBOMs** — CycloneDX per GHCR image + source tree, attached to every
|
||||||
|
GitHub Release.
|
||||||
|
- Requires `TAURI_SIGNING_PRIVATE_KEY` repo secret (keypair generated
|
||||||
|
locally; private half never committed).
|
||||||
|
|
||||||
|
## Sandboxed functions (unreleased)
|
||||||
|
|
||||||
|
- `FUNCTIONS_DRIVER=docker` — every invoke runs in an ephemeral sibling
|
||||||
|
container: `--network none`, read-only rootfs, 128 MiB / 0.5 CPU / 64
|
||||||
|
pids, non-root, `--cap-drop ALL`. Source travels as base64 env — no
|
||||||
|
bind mount, so Docker Desktop and remote daemons work. Timeout
|
||||||
|
force-removes the named container.
|
||||||
|
- `exec` remains the default for single-team deployments; the trust-model
|
||||||
|
doc describes both levels.
|
||||||
|
|
||||||
|
## Client events + presence (unreleased)
|
||||||
|
|
||||||
|
- `POST /projects/:id/events` — members/API keys publish `custom.*`
|
||||||
|
events through the standard fan-out (realtime + webhooks + functions);
|
||||||
|
system types can't be spoofed.
|
||||||
|
- Presence — per-project realtime subscriber counts; `presence.update`
|
||||||
|
broadcasts on join/leave (stream-only), `GET …/realtime/presence`
|
||||||
|
reads the count. SDK: `publish()` + `presence()`; CLI: `events:send`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ RUN apt-get update \
|
|||||||
&& chmod +x /usr/local/bin/dbx-mcp \
|
&& chmod +x /usr/local/bin/dbx-mcp \
|
||||||
&& rm /tmp/dbx-mcp.tar.gz
|
&& rm /tmp/dbx-mcp.tar.gz
|
||||||
|
|
||||||
|
# Functions runtime — bun static binary from the official image (multi-arch).
|
||||||
|
COPY --from=oven/bun:1.3.14 /usr/local/bin/bun /usr/local/bin/bun
|
||||||
|
|
||||||
COPY --from=builder /primora-backend /usr/local/bin/primora-backend
|
COPY --from=builder /primora-backend /usr/local/bin/primora-backend
|
||||||
COPY --from=builder /app/db ./db
|
COPY --from=builder /app/db ./db
|
||||||
COPY --from=builder /app/openapi ./openapi
|
COPY --from=builder /app/openapi ./openapi
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE core.alert_rules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('heartbeat_silence','error_spike')),
|
||||||
|
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
state JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (project_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS core.alert_rules;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE core.inbound_hooks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
mode TEXT NOT NULL DEFAULT 'event' CHECK (mode IN ('event','job')),
|
||||||
|
job_id UUID REFERENCES core.scheduled_jobs(id) ON DELETE CASCADE,
|
||||||
|
secret BYTEA NOT NULL DEFAULT ''::bytea,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_received_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (project_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||||
|
CHECK (triggered_by IN ('schedule','manual','hook'));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||||
|
CHECK (triggered_by IN ('schedule','manual'));
|
||||||
|
DROP TABLE IF EXISTS core.inbound_hooks;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE core.email_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
template TEXT NOT NULL,
|
||||||
|
to_email TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('sent','failed')),
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_email_log_project ON core.email_log(project_id, created_at DESC);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS core.email_log;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE core.functions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
runtime TEXT NOT NULL DEFAULT 'bun' CHECK (runtime IN ('bun','deno')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (project_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE core.function_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
function_id UUID NOT NULL REFERENCES core.functions(id) ON DELETE CASCADE,
|
||||||
|
trigger TEXT NOT NULL CHECK (trigger IN ('manual','schedule','hook')),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('success','error','timeout')),
|
||||||
|
exit_code INT,
|
||||||
|
stdout TEXT NOT NULL DEFAULT '',
|
||||||
|
stderr TEXT NOT NULL DEFAULT '',
|
||||||
|
duration_ms INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX function_runs_function_idx ON core.function_runs(function_id, created_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||||
|
CHECK (triggered_by IN ('schedule','manual','hook','function'));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||||
|
ALTER TABLE core.scheduled_job_runs
|
||||||
|
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||||
|
CHECK (triggered_by IN ('schedule','manual','hook'));
|
||||||
|
DROP TABLE IF EXISTS core.function_runs;
|
||||||
|
DROP TABLE IF EXISTS core.functions;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE core.scheduled_jobs
|
||||||
|
ADD COLUMN function_id UUID REFERENCES core.functions(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE core.inbound_hooks
|
||||||
|
ADD COLUMN function_id UUID REFERENCES core.functions(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE core.inbound_hooks
|
||||||
|
DROP CONSTRAINT inbound_hooks_mode_check;
|
||||||
|
ALTER TABLE core.inbound_hooks
|
||||||
|
ADD CONSTRAINT inbound_hooks_mode_check
|
||||||
|
CHECK (mode IN ('event','job','function'));
|
||||||
|
|
||||||
|
ALTER TABLE core.functions
|
||||||
|
ADD COLUMN event_pattern TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE core.function_runs
|
||||||
|
DROP CONSTRAINT function_runs_trigger_check;
|
||||||
|
ALTER TABLE core.function_runs
|
||||||
|
ADD CONSTRAINT function_runs_trigger_check
|
||||||
|
CHECK (trigger IN ('manual','schedule','hook','event'));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE core.function_runs
|
||||||
|
DROP CONSTRAINT function_runs_trigger_check;
|
||||||
|
ALTER TABLE core.function_runs
|
||||||
|
ADD CONSTRAINT function_runs_trigger_check
|
||||||
|
CHECK (trigger IN ('manual','schedule','hook'));
|
||||||
|
ALTER TABLE core.functions DROP COLUMN IF EXISTS event_pattern;
|
||||||
|
ALTER TABLE core.inbound_hooks
|
||||||
|
DROP CONSTRAINT inbound_hooks_mode_check;
|
||||||
|
ALTER TABLE core.inbound_hooks
|
||||||
|
ADD CONSTRAINT inbound_hooks_mode_check CHECK (mode IN ('event','job'));
|
||||||
|
ALTER TABLE core.inbound_hooks DROP COLUMN IF EXISTS function_id;
|
||||||
|
ALTER TABLE core.scheduled_jobs DROP COLUMN IF EXISTS function_id;
|
||||||
@@ -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,
|
payload,
|
||||||
enabled,
|
enabled,
|
||||||
next_run_at,
|
next_run_at,
|
||||||
created_by_user_id
|
created_by_user_id,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
function_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpdateScheduledJob :one
|
-- name: UpdateScheduledJob :one
|
||||||
@@ -30,6 +31,7 @@ SET name = $3,
|
|||||||
next_run_at = $7,
|
next_run_at = $7,
|
||||||
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
|
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
|
||||||
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
|
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
|
||||||
|
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND project_id = $2
|
AND project_id = $2
|
||||||
|
|||||||
@@ -147,3 +147,7 @@ WHERE p.organization_id = $1;
|
|||||||
DELETE FROM core.organizations
|
DELETE FROM core.organizations
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetOrganizationByID :one
|
||||||
|
SELECT * FROM core.organizations
|
||||||
|
WHERE id = $1;
|
||||||
@@ -11,7 +11,10 @@ WHERE id = $1;
|
|||||||
SELECT * FROM core.webhooks
|
SELECT * FROM core.webhooks
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
AND enabled
|
AND enabled
|
||||||
AND (cardinality(events) = 0 OR $2::text = ANY(events));
|
AND (cardinality(events) = 0 OR EXISTS (
|
||||||
|
SELECT 1 FROM unnest(events) AS e
|
||||||
|
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
|
||||||
|
));
|
||||||
|
|
||||||
-- name: CreateWebhook :one
|
-- name: CreateWebhook :one
|
||||||
INSERT INTO core.webhooks (
|
INSERT INTO core.webhooks (
|
||||||
|
|||||||
@@ -81,17 +81,45 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := storage.NewLocalStore(cfg.StorageRoot)
|
var store storage.Store
|
||||||
|
if cfg.StorageDriver == "s3" {
|
||||||
|
s3Store, err := storage.NewS3Store(storage.S3Config{
|
||||||
|
Endpoint: cfg.S3Endpoint,
|
||||||
|
Region: cfg.S3Region,
|
||||||
|
Bucket: cfg.S3Bucket,
|
||||||
|
AccessKeyID: cfg.S3AccessKeyID,
|
||||||
|
SecretAccessKey: cfg.S3SecretAccessKey,
|
||||||
|
PublicEndpoint: cfg.S3PublicEndpoint,
|
||||||
|
Prefix: cfg.S3Prefix,
|
||||||
|
PathStyle: cfg.S3PathStyle,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("s3 storage: %w", err)
|
||||||
|
}
|
||||||
|
if err := s3Store.EnsureBucket(context.Background()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
store = s3Store
|
||||||
|
} else {
|
||||||
|
localStore, err := storage.NewLocalStore(cfg.StorageRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
store = localStore
|
||||||
|
}
|
||||||
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
|
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
|
||||||
repo := repositories.NewCoreRepository(dbPool)
|
repo := repositories.NewCoreRepository(dbPool)
|
||||||
encryptor, err := secrets.NewEncryptor(cfg.EncryptionKey)
|
encryptor, err := secrets.NewEncryptor(cfg.EncryptionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("encryption key: %w", err)
|
return nil, fmt.Errorf("encryption key: %w", err)
|
||||||
}
|
}
|
||||||
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger)
|
var fnRunner services.FunctionRunner
|
||||||
|
if cfg.FunctionsDriver == "docker" {
|
||||||
|
fnRunner = services.NewDockerRunner(cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
|
||||||
|
} else {
|
||||||
|
fnRunner = services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
|
||||||
|
}
|
||||||
|
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, fnRunner)
|
||||||
settings := services.NewSettingsService(repo, encryptor)
|
settings := services.NewSettingsService(repo, encryptor)
|
||||||
|
|
||||||
if cfg.Env == "production" {
|
if cfg.Env == "production" {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -35,6 +36,26 @@ type Config struct {
|
|||||||
SMTPSecure bool
|
SMTPSecure bool
|
||||||
|
|
||||||
EncryptionKey string
|
EncryptionKey string
|
||||||
|
|
||||||
|
// Object storage: "local" (BACKEND_STORAGE_ROOT) or "s3".
|
||||||
|
StorageDriver string
|
||||||
|
S3Endpoint string
|
||||||
|
S3Region string
|
||||||
|
S3Bucket string
|
||||||
|
S3AccessKeyID string
|
||||||
|
S3SecretAccessKey string
|
||||||
|
S3PublicEndpoint string // presigned-URL host when it differs from Endpoint
|
||||||
|
S3Prefix string
|
||||||
|
S3PathStyle bool
|
||||||
|
|
||||||
|
// Functions runtime: driver "exec" (runtime binary on the backend host)
|
||||||
|
// or "docker" (ephemeral locked-down container), binary name/path for
|
||||||
|
// exec ("auto" resolves per-function runtime from PATH), timeout and
|
||||||
|
// output cap.
|
||||||
|
FunctionsDriver string
|
||||||
|
FunctionsRuntime string
|
||||||
|
FunctionsTimeout time.Duration
|
||||||
|
FunctionsMaxBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// devEncryptionKey is the built-in credential key for local development only.
|
// devEncryptionKey is the built-in credential key for local development only.
|
||||||
@@ -63,6 +84,17 @@ func Load() (Config, error) {
|
|||||||
SMTPUser: os.Getenv("SMTP_USER"),
|
SMTPUser: os.Getenv("SMTP_USER"),
|
||||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||||
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
|
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
|
||||||
|
StorageDriver: getenv("BACKEND_STORAGE_DRIVER", "local"),
|
||||||
|
S3Endpoint: os.Getenv("S3_ENDPOINT"),
|
||||||
|
S3Region: getenv("S3_REGION", "us-east-1"),
|
||||||
|
S3Bucket: os.Getenv("S3_BUCKET"),
|
||||||
|
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
|
||||||
|
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
||||||
|
S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"),
|
||||||
|
S3Prefix: os.Getenv("S3_PREFIX"),
|
||||||
|
FunctionsDriver: getenv("FUNCTIONS_DRIVER", "exec"),
|
||||||
|
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
|
||||||
|
FunctionsMaxBytes: 65536,
|
||||||
}
|
}
|
||||||
|
|
||||||
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
||||||
@@ -95,6 +127,24 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.SMTPSecure = smtpSecure
|
cfg.SMTPSecure = smtpSecure
|
||||||
|
|
||||||
|
s3PathStyle, err := strconv.ParseBool(getenv("S3_PATH_STYLE", "true"))
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("parse S3_PATH_STYLE: %w", err)
|
||||||
|
}
|
||||||
|
cfg.S3PathStyle = s3PathStyle
|
||||||
|
|
||||||
|
fnTimeout, err := strconv.Atoi(getenv("FUNCTIONS_TIMEOUT_SECONDS", "30"))
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("parse FUNCTIONS_TIMEOUT_SECONDS: %w", err)
|
||||||
|
}
|
||||||
|
cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second
|
||||||
|
|
||||||
|
switch cfg.FunctionsDriver {
|
||||||
|
case "exec", "docker":
|
||||||
|
default:
|
||||||
|
return Config{}, fmt.Errorf("FUNCTIONS_DRIVER %q must be exec or docker", cfg.FunctionsDriver)
|
||||||
|
}
|
||||||
|
|
||||||
var missing []string
|
var missing []string
|
||||||
if cfg.DatabaseURL == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
missing = append(missing, "DATABASE_URL")
|
missing = append(missing, "DATABASE_URL")
|
||||||
@@ -102,9 +152,24 @@ func Load() (Config, error) {
|
|||||||
if cfg.JWTSecret == "" {
|
if cfg.JWTSecret == "" {
|
||||||
missing = append(missing, "JWT_SECRET")
|
missing = append(missing, "JWT_SECRET")
|
||||||
}
|
}
|
||||||
|
switch cfg.StorageDriver {
|
||||||
|
case "local":
|
||||||
if cfg.StorageRoot == "" {
|
if cfg.StorageRoot == "" {
|
||||||
missing = append(missing, "BACKEND_STORAGE_ROOT")
|
missing = append(missing, "BACKEND_STORAGE_ROOT")
|
||||||
}
|
}
|
||||||
|
case "s3":
|
||||||
|
if cfg.S3Bucket == "" {
|
||||||
|
missing = append(missing, "S3_BUCKET")
|
||||||
|
}
|
||||||
|
if cfg.S3AccessKeyID == "" {
|
||||||
|
missing = append(missing, "S3_ACCESS_KEY_ID")
|
||||||
|
}
|
||||||
|
if cfg.S3SecretAccessKey == "" {
|
||||||
|
missing = append(missing, "S3_SECRET_ACCESS_KEY")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return Config{}, fmt.Errorf("unknown BACKEND_STORAGE_DRIVER %q (want local or s3)", cfg.StorageDriver)
|
||||||
|
}
|
||||||
if cfg.AuthInternalBaseURL == "" {
|
if cfg.AuthInternalBaseURL == "" {
|
||||||
missing = append(missing, "AUTH_INTERNAL_BASE_URL")
|
missing = append(missing, "AUTH_INTERNAL_BASE_URL")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: alert_rules.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createAlertRule = `-- name: CreateAlertRule :one
|
||||||
|
INSERT INTO core.alert_rules (project_id, name, kind, config)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateAlertRuleParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Config []byte `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createAlertRule,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Kind,
|
||||||
|
arg.Config,
|
||||||
|
)
|
||||||
|
var i CoreAlertRule
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAlertRule = `-- name: DeleteAlertRule :exec
|
||||||
|
DELETE FROM core.alert_rules
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteAlertRuleParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteAlertRule, arg.ID, arg.ProjectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAlertRule = `-- name: GetAlertRule :one
|
||||||
|
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAlertRuleParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getAlertRule, arg.ID, arg.ProjectID)
|
||||||
|
var i CoreAlertRule
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastHeartbeats = `-- name: LastHeartbeats :many
|
||||||
|
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
|
||||||
|
FROM core.components co
|
||||||
|
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
|
||||||
|
WHERE co.project_id = $1
|
||||||
|
GROUP BY co.name
|
||||||
|
`
|
||||||
|
|
||||||
|
type LastHeartbeatsRow struct {
|
||||||
|
ComponentName string `json:"component_name"`
|
||||||
|
LastBeat pgtype.Timestamptz `json:"last_beat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-component most recent heartbeat — heartbeat_silence detection.
|
||||||
|
func (q *Queries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, lastHeartbeats, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []LastHeartbeatsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i LastHeartbeatsRow
|
||||||
|
if err := rows.Scan(&i.ComponentName, &i.LastBeat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAlertRules = `-- name: ListAlertRules :many
|
||||||
|
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAlertRules, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreAlertRule
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreAlertRule
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEnabledAlertRules = `-- name: ListEnabledAlertRules :many
|
||||||
|
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||||
|
WHERE enabled
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listEnabledAlertRules)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreAlertRule
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreAlertRule
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentErrorCounts = `-- name: RecentErrorCounts :many
|
||||||
|
SELECT co.name AS component_name, count(*) AS errors
|
||||||
|
FROM core.events e
|
||||||
|
JOIN core.components co ON co.id = e.component_id
|
||||||
|
WHERE e.project_id = $1 AND e.type = 'error'
|
||||||
|
AND e.ts > now() - make_interval(secs => $2::bigint)
|
||||||
|
GROUP BY co.name
|
||||||
|
`
|
||||||
|
|
||||||
|
type RecentErrorCountsParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Column2 int64 `json:"column_2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecentErrorCountsRow struct {
|
||||||
|
ComponentName string `json:"component_name"`
|
||||||
|
Errors int64 `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-component error count inside a sliding window — error_spike detection.
|
||||||
|
func (q *Queries) RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, recentErrorCounts, arg.ProjectID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecentErrorCountsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecentErrorCountsRow
|
||||||
|
if err := rows.Scan(&i.ComponentName, &i.Errors); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAlertRuleState = `-- name: SetAlertRuleState :exec
|
||||||
|
UPDATE core.alert_rules
|
||||||
|
SET state = $3, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetAlertRuleStateParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
State []byte `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, setAlertRuleState, arg.ID, arg.ProjectID, arg.State)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAlertRule = `-- name: UpdateAlertRule :one
|
||||||
|
UPDATE core.alert_rules
|
||||||
|
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateAlertRuleParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Config []byte `json:"config"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateAlertRule,
|
||||||
|
arg.ID,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Config,
|
||||||
|
arg.Enabled,
|
||||||
|
)
|
||||||
|
var i CoreAlertRule
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: email_log.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const insertEmailLog = `-- name: InsertEmailLog :one
|
||||||
|
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, project_id, template, to_email, subject, status, error, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertEmailLogParams struct {
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
ToEmail string `json:"to_email"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertEmailLog,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Template,
|
||||||
|
arg.ToEmail,
|
||||||
|
arg.Subject,
|
||||||
|
arg.Status,
|
||||||
|
arg.Error,
|
||||||
|
)
|
||||||
|
var i CoreEmailLog
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Template,
|
||||||
|
&i.ToEmail,
|
||||||
|
&i.Subject,
|
||||||
|
&i.Status,
|
||||||
|
&i.Error,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEmailLog = `-- name: ListEmailLog :many
|
||||||
|
SELECT id, project_id, template, to_email, subject, status, error, created_at FROM core.email_log
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListEmailLogParams struct {
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listEmailLog, arg.ProjectID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreEmailLog
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreEmailLog
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Template,
|
||||||
|
&i.ToEmail,
|
||||||
|
&i.Subject,
|
||||||
|
&i.Status,
|
||||||
|
&i.Error,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: functions.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createFunction = `-- name: CreateFunction :one
|
||||||
|
INSERT INTO core.functions (project_id, name, code, runtime, event_pattern)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateFunctionParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
EventPattern string `json:"event_pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createFunction,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Code,
|
||||||
|
arg.Runtime,
|
||||||
|
arg.EventPattern,
|
||||||
|
)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFunction = `-- name: DeleteFunction :exec
|
||||||
|
DELETE FROM core.functions WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteFunctionParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteFunction, arg.ID, arg.ProjectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFunction = `-- name: GetFunction :one
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFunction, id)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFunctionByName = `-- name: GetFunctionByName :one
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE project_id = $1 AND name = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetFunctionByNameParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFunctionByName, arg.ProjectID, arg.Name)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertFunctionRun = `-- name: InsertFunctionRun :one
|
||||||
|
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertFunctionRunParams struct {
|
||||||
|
FunctionID uuid.UUID `json:"function_id"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode *int32 `json:"exit_code"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertFunctionRun,
|
||||||
|
arg.FunctionID,
|
||||||
|
arg.Trigger,
|
||||||
|
arg.Status,
|
||||||
|
arg.ExitCode,
|
||||||
|
arg.Stdout,
|
||||||
|
arg.Stderr,
|
||||||
|
arg.DurationMs,
|
||||||
|
)
|
||||||
|
var i CoreFunctionRun
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FunctionID,
|
||||||
|
&i.Trigger,
|
||||||
|
&i.Status,
|
||||||
|
&i.ExitCode,
|
||||||
|
&i.Stdout,
|
||||||
|
&i.Stderr,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFunctionRuns = `-- name: ListFunctionRuns :many
|
||||||
|
SELECT id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at FROM core.function_runs
|
||||||
|
WHERE function_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListFunctionRunsParams struct {
|
||||||
|
FunctionID uuid.UUID `json:"function_id"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listFunctionRuns, arg.FunctionID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreFunctionRun
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreFunctionRun
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FunctionID,
|
||||||
|
&i.Trigger,
|
||||||
|
&i.Status,
|
||||||
|
&i.ExitCode,
|
||||||
|
&i.Stdout,
|
||||||
|
&i.Stderr,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFunctions = `-- name: ListFunctions :many
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listFunctions, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreFunction
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreFunction
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFunctionsForEvent = `-- name: ListFunctionsForEvent :many
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND enabled = TRUE
|
||||||
|
AND event_pattern <> ''
|
||||||
|
AND ($2 LIKE REPLACE(event_pattern, '*', '%') OR event_pattern = '*')
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListFunctionsForEventParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
EventPattern string `json:"event_pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listFunctionsForEvent, arg.ProjectID, arg.EventPattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreFunction
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreFunction
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFunction = `-- name: UpdateFunction :one
|
||||||
|
UPDATE core.functions
|
||||||
|
SET code = COALESCE($3, code),
|
||||||
|
enabled = COALESCE($4, enabled),
|
||||||
|
event_pattern = COALESCE($5, event_pattern),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateFunctionParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Code *string `json:"code"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
EventPattern *string `json:"event_pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateFunction,
|
||||||
|
arg.ID,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Code,
|
||||||
|
arg.Enabled,
|
||||||
|
arg.EventPattern,
|
||||||
|
)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: inbound_hooks.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createInboundHook = `-- name: CreateInboundHook :one
|
||||||
|
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, function_id, secret, enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateInboundHookParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
JobID pgtype.UUID `json:"job_id"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
|
Secret []byte `json:"secret"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createInboundHook,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Token,
|
||||||
|
arg.Mode,
|
||||||
|
arg.JobID,
|
||||||
|
arg.FunctionID,
|
||||||
|
arg.Secret,
|
||||||
|
arg.Enabled,
|
||||||
|
)
|
||||||
|
var i CoreInboundHook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Token,
|
||||||
|
&i.Mode,
|
||||||
|
&i.JobID,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastReceivedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteInboundHook = `-- name: DeleteInboundHook :exec
|
||||||
|
DELETE FROM core.inbound_hooks
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteInboundHookParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteInboundHook, arg.ID, arg.ProjectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInboundHook = `-- name: GetInboundHook :one
|
||||||
|
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetInboundHookParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getInboundHook, arg.ID, arg.ProjectID)
|
||||||
|
var i CoreInboundHook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Token,
|
||||||
|
&i.Mode,
|
||||||
|
&i.JobID,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastReceivedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInboundHookByToken = `-- name: GetInboundHookByToken :one
|
||||||
|
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||||
|
WHERE token = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getInboundHookByToken, token)
|
||||||
|
var i CoreInboundHook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Token,
|
||||||
|
&i.Mode,
|
||||||
|
&i.JobID,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastReceivedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listInboundHooks = `-- name: ListInboundHooks :many
|
||||||
|
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listInboundHooks, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreInboundHook
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreInboundHook
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Token,
|
||||||
|
&i.Mode,
|
||||||
|
&i.JobID,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastReceivedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchInboundHook = `-- name: TouchInboundHook :exec
|
||||||
|
UPDATE core.inbound_hooks
|
||||||
|
SET last_received_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) TouchInboundHook(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, touchInboundHook, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -22,9 +22,10 @@ INSERT INTO core.scheduled_jobs (
|
|||||||
payload,
|
payload,
|
||||||
enabled,
|
enabled,
|
||||||
next_run_at,
|
next_run_at,
|
||||||
created_by_user_id
|
created_by_user_id,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
function_id
|
||||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateScheduledJobParams struct {
|
type CreateScheduledJobParams struct {
|
||||||
@@ -37,6 +38,7 @@ type CreateScheduledJobParams struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
|
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
|
||||||
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) {
|
func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) {
|
||||||
@@ -50,6 +52,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob
|
|||||||
arg.Enabled,
|
arg.Enabled,
|
||||||
arg.NextRunAt,
|
arg.NextRunAt,
|
||||||
arg.CreatedByUserID,
|
arg.CreatedByUserID,
|
||||||
|
arg.FunctionID,
|
||||||
)
|
)
|
||||||
var i CoreScheduledJob
|
var i CoreScheduledJob
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -67,6 +70,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob
|
|||||||
&i.CreatedByUserID,
|
&i.CreatedByUserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -133,7 +137,7 @@ func (q *Queries) FinishScheduledJobRun(ctx context.Context, arg FinishScheduled
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getScheduledJobByID = `-- name: GetScheduledJobByID :one
|
const getScheduledJobByID = `-- name: GetScheduledJobByID :one
|
||||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
|
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -155,6 +159,7 @@ func (q *Queries) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreSc
|
|||||||
&i.CreatedByUserID,
|
&i.CreatedByUserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -188,7 +193,7 @@ func (q *Queries) InsertScheduledJobRun(ctx context.Context, arg InsertScheduled
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many
|
const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many
|
||||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
|
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
|
||||||
WHERE enabled
|
WHERE enabled
|
||||||
AND next_run_at IS NOT NULL
|
AND next_run_at IS NOT NULL
|
||||||
AND next_run_at <= $1
|
AND next_run_at <= $1
|
||||||
@@ -218,6 +223,7 @@ func (q *Queries) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Tim
|
|||||||
&i.CreatedByUserID,
|
&i.CreatedByUserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -272,7 +278,7 @@ func (q *Queries) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJob
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listScheduledJobs = `-- name: ListScheduledJobs :many
|
const listScheduledJobs = `-- name: ListScheduledJobs :many
|
||||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
|
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`
|
`
|
||||||
@@ -301,6 +307,7 @@ func (q *Queries) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([
|
|||||||
&i.CreatedByUserID,
|
&i.CreatedByUserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -319,7 +326,7 @@ SET last_run_at = $2,
|
|||||||
next_run_at = $4,
|
next_run_at = $4,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
|
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type MarkScheduledJobRanParams struct {
|
type MarkScheduledJobRanParams struct {
|
||||||
@@ -352,6 +359,7 @@ func (q *Queries) MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobR
|
|||||||
&i.CreatedByUserID,
|
&i.CreatedByUserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -386,10 +394,11 @@ SET name = $3,
|
|||||||
next_run_at = $7,
|
next_run_at = $7,
|
||||||
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
|
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
|
||||||
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
|
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
|
||||||
|
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND project_id = $2
|
AND project_id = $2
|
||||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
|
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateScheduledJobParams struct {
|
type UpdateScheduledJobParams struct {
|
||||||
@@ -404,6 +413,8 @@ type UpdateScheduledJobParams struct {
|
|||||||
Secret []byte `json:"secret"`
|
Secret []byte `json:"secret"`
|
||||||
Column10 bool `json:"column_10"`
|
Column10 bool `json:"column_10"`
|
||||||
Payload []byte `json:"payload"`
|
Payload []byte `json:"payload"`
|
||||||
|
Column12 bool `json:"column_12"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) {
|
func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) {
|
||||||
@@ -419,6 +430,8 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob
|
|||||||
arg.Secret,
|
arg.Secret,
|
||||||
arg.Column10,
|
arg.Column10,
|
||||||
arg.Payload,
|
arg.Payload,
|
||||||
|
arg.Column12,
|
||||||
|
arg.FunctionID,
|
||||||
)
|
)
|
||||||
var i CoreScheduledJob
|
var i CoreScheduledJob
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -436,6 +449,7 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob
|
|||||||
&i.CreatedByUserID,
|
&i.CreatedByUserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -140,6 +140,18 @@ func (ns NullCoreProjectRole) Value() (driver.Value, error) {
|
|||||||
return string(ns.CoreProjectRole), nil
|
return string(ns.CoreProjectRole), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreAlertRule struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Config []byte `json:"config"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
State []byte `json:"state"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreApiKey struct {
|
type CoreApiKey struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -231,6 +243,17 @@ type CoreDocument struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreEmailLog struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
ToEmail string `json:"to_email"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreEvent struct {
|
type CoreEvent struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -243,6 +266,45 @@ type CoreEvent struct {
|
|||||||
Ts pgtype.Timestamptz `json:"ts"`
|
Ts pgtype.Timestamptz `json:"ts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreFunction struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
EventPattern string `json:"event_pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoreFunctionRun struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
FunctionID uuid.UUID `json:"function_id"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode *int32 `json:"exit_code"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoreInboundHook struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
JobID pgtype.UUID `json:"job_id"`
|
||||||
|
Secret []byte `json:"secret"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastReceivedAt pgtype.Timestamptz `json:"last_received_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreIntegration struct {
|
type CoreIntegration struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -333,6 +395,7 @@ type CoreScheduledJob struct {
|
|||||||
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoreScheduledJobRun struct {
|
type CoreScheduledJobRun struct {
|
||||||
|
|||||||
@@ -241,6 +241,23 @@ func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getOrganizationByID = `-- name: GetOrganizationByID :one
|
||||||
|
SELECT id, slug, name, created_at FROM core.organizations
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getOrganizationByID, id)
|
||||||
|
var i CoreOrganization
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Name,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
|
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
|
||||||
SELECT
|
SELECT
|
||||||
om.id, om.organization_id, om.user_id, om.role, om.created_at,
|
om.id, om.organization_id, om.user_id, om.role, om.created_at,
|
||||||
|
|||||||
@@ -23,24 +23,30 @@ type Querier interface {
|
|||||||
CountOrganizations(ctx context.Context) (int64, error)
|
CountOrganizations(ctx context.Context) (int64, error)
|
||||||
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
|
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||||
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
|
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
|
||||||
|
CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error)
|
||||||
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
|
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
|
||||||
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
|
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
|
||||||
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
|
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
|
||||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
||||||
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
||||||
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
||||||
|
CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error)
|
||||||
|
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
|
||||||
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
||||||
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
||||||
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
|
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
|
||||||
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
|
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
|
||||||
CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error)
|
CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error)
|
||||||
CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error)
|
CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error)
|
||||||
|
DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error
|
||||||
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
|
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
|
||||||
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||||
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
||||||
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
||||||
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
||||||
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
||||||
|
DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error
|
||||||
|
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
|
||||||
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
||||||
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||||
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||||
@@ -54,6 +60,7 @@ type Querier interface {
|
|||||||
FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error)
|
FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||||
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
|
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
|
||||||
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
|
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
|
||||||
|
GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error)
|
||||||
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
|
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
|
||||||
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||||
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
|
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
|
||||||
@@ -62,9 +69,14 @@ type Querier interface {
|
|||||||
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
||||||
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
||||||
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
||||||
|
GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error)
|
||||||
|
GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error)
|
||||||
|
GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error)
|
||||||
|
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error)
|
||||||
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
|
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
|
||||||
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||||
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
||||||
|
GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||||
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
|
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
|
||||||
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
||||||
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
|
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
|
||||||
@@ -77,10 +89,15 @@ type Querier interface {
|
|||||||
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
|
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
|
||||||
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
|
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
|
||||||
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
|
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
|
||||||
|
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error)
|
||||||
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
||||||
|
InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error)
|
||||||
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||||
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
|
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
|
||||||
|
// Per-component most recent heartbeat — heartbeat_silence detection.
|
||||||
|
LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error)
|
||||||
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
|
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
|
||||||
|
ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error)
|
||||||
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
||||||
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
||||||
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
|
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
|
||||||
@@ -90,8 +107,14 @@ type Querier interface {
|
|||||||
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
|
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
|
||||||
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
||||||
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
|
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
|
||||||
|
ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error)
|
||||||
|
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
||||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||||
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
||||||
|
ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error)
|
||||||
|
ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error)
|
||||||
|
ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error)
|
||||||
|
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
|
||||||
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
||||||
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
||||||
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
||||||
@@ -114,10 +137,13 @@ type Querier interface {
|
|||||||
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
|
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
|
||||||
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
||||||
PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error
|
PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error
|
||||||
|
// Per-component error count inside a sliding window — error_spike detection.
|
||||||
|
RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error)
|
||||||
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
|
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
|
||||||
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
||||||
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
|
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
|
||||||
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
|
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
|
||||||
|
SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error
|
||||||
SweepExpiredAuditLogs(ctx context.Context) (int64, error)
|
SweepExpiredAuditLogs(ctx context.Context) (int64, error)
|
||||||
// Per-project retention sweeps. A retention of 0 days disables the sweep for
|
// Per-project retention sweeps. A retention of 0 days disables the sweep for
|
||||||
// that stream. Pending webhook deliveries are never swept — they may still be
|
// that stream. Pending webhook deliveries are never swept — they may still be
|
||||||
@@ -128,9 +154,12 @@ type Querier interface {
|
|||||||
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
|
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
|
||||||
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
|
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
|
||||||
TouchAPIKey(ctx context.Context, id uuid.UUID) error
|
TouchAPIKey(ctx context.Context, id uuid.UUID) error
|
||||||
|
TouchInboundHook(ctx context.Context, id uuid.UUID) error
|
||||||
|
UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error)
|
||||||
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
||||||
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
||||||
|
UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error)
|
||||||
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
|
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
|
||||||
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
|
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
|
||||||
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
||||||
|
|||||||
@@ -291,7 +291,10 @@ const listWebhooksForEvent = `-- name: ListWebhooksForEvent :many
|
|||||||
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
|
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
AND enabled
|
AND enabled
|
||||||
AND (cardinality(events) = 0 OR $2::text = ANY(events))
|
AND (cardinality(events) = 0 OR EXISTS (
|
||||||
|
SELECT 1 FROM unnest(events) AS e
|
||||||
|
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
|
||||||
|
))
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListWebhooksForEventParams struct {
|
type ListWebhooksForEventParams struct {
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
@@ -35,6 +37,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
|
|
||||||
api := router.Group("/api/v1")
|
api := router.Group("/api/v1")
|
||||||
api.GET("/me", h.me)
|
api.GET("/me", h.me)
|
||||||
|
api.GET("/context", h.actorContext)
|
||||||
api.GET("/instance/public", h.instancePublic)
|
api.GET("/instance/public", h.instancePublic)
|
||||||
api.GET("/instance/settings", h.listInstanceSettings)
|
api.GET("/instance/settings", h.listInstanceSettings)
|
||||||
api.PUT("/instance/settings/:key", h.updateInstanceSetting)
|
api.PUT("/instance/settings/:key", h.updateInstanceSetting)
|
||||||
@@ -79,7 +82,20 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
||||||
api.OPTIONS("/ingest", h.ingestOptions)
|
api.OPTIONS("/ingest", h.ingestOptions)
|
||||||
api.POST("/ingest", h.ingest)
|
api.POST("/ingest", h.ingest)
|
||||||
|
api.POST("/hooks/:token", h.receiveInboundHook)
|
||||||
|
api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks)
|
||||||
|
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
|
||||||
|
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
|
||||||
|
api.GET("/projects/:projectID/emails", h.listEmailLog)
|
||||||
|
api.GET("/projects/:projectID/functions", h.listFunctions)
|
||||||
|
api.POST("/projects/:projectID/functions", h.createFunction)
|
||||||
|
api.GET("/projects/:projectID/functions/:functionID", h.getFunction)
|
||||||
|
api.PUT("/projects/:projectID/functions/:functionID", h.updateFunction)
|
||||||
|
api.DELETE("/projects/:projectID/functions/:functionID", h.deleteFunction)
|
||||||
|
api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction)
|
||||||
|
api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns)
|
||||||
api.GET("/projects/:projectID/events", h.listEvents)
|
api.GET("/projects/:projectID/events", h.listEvents)
|
||||||
|
api.POST("/projects/:projectID/events", h.publishEvent)
|
||||||
api.GET("/projects/:projectID/issues", h.listIssues)
|
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||||
api.GET("/projects/:projectID/components", h.listComponents)
|
api.GET("/projects/:projectID/components", h.listComponents)
|
||||||
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
|
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
|
||||||
@@ -121,10 +137,17 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret)
|
api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret)
|
||||||
api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret)
|
api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret)
|
||||||
api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret)
|
api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret)
|
||||||
|
|
||||||
|
api.GET("/projects/:projectID/alerts", h.listAlertRules)
|
||||||
|
api.POST("/projects/:projectID/alerts", h.createAlertRule)
|
||||||
|
api.PUT("/projects/:projectID/alerts/:ruleID", h.updateAlertRule)
|
||||||
|
api.DELETE("/projects/:projectID/alerts/:ruleID", h.deleteAlertRule)
|
||||||
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
|
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
|
||||||
|
api.GET("/projects/:projectID/realtime/presence", h.realtimePresence)
|
||||||
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
||||||
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
||||||
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
|
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
|
||||||
|
api.POST("/buckets/:bucketID/object-presigns", h.presignObject)
|
||||||
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
|
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
|
||||||
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
|
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
|
||||||
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
|
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
|
||||||
@@ -161,6 +184,19 @@ func (h *HTTPHandler) me(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) actorContext(c *gin.Context) {
|
||||||
|
actor, ok := middleware.RequireActor(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ActorContext(c.Request.Context(), actor)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) bootstrap(c *gin.Context) {
|
func (h *HTTPHandler) bootstrap(c *gin.Context) {
|
||||||
actor, ok := middleware.RequireActor(c)
|
actor, ok := middleware.RequireActor(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -874,6 +910,43 @@ func (h *HTTPHandler) updateObject(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type presignObjectRequest struct {
|
||||||
|
Key string `json:"key" validate:"required"`
|
||||||
|
Op string `json:"op" validate:"omitempty,oneof=download upload"`
|
||||||
|
TTLSeconds int `json:"ttl_seconds" validate:"omitempty,min=60,max=3600"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// presignObject returns a short-lived URL clients call against storage
|
||||||
|
// directly — keeps large transfers off the backend. S3 driver only.
|
||||||
|
func (h *HTTPHandler) presignObject(c *gin.Context) {
|
||||||
|
actor, ok := middleware.RequireActor(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucketID, ok := parseUUIDParam(c, "bucketID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body presignObjectRequest
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Platform.PresignObject(c.Request.Context(), actor, bucketID, strings.TrimSpace(body.Key), body.Op, body.TTLSeconds)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, services.ErrPresignUnsupported) {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "presign_unsupported", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) deleteObject(c *gin.Context) {
|
func (h *HTTPHandler) deleteObject(c *gin.Context) {
|
||||||
actor, ok := middleware.RequireActor(c)
|
actor, ok := middleware.RequireActor(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1207,17 +1280,58 @@ func (h *HTTPHandler) bindAndValidate(c *gin.Context, target any) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if err := h.Validate.Struct(target); err != nil {
|
if err := h.Validate.Struct(target); err != nil {
|
||||||
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", err.Error())
|
var valErrs validator.ValidationErrors
|
||||||
|
if errors.As(err, &valErrs) && len(valErrs) > 0 {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", formatFieldError(valErrs[0]))
|
||||||
|
} else {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", "invalid request body")
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatFieldError(f validator.FieldError) string {
|
||||||
|
if f.Tag() == "required" {
|
||||||
|
return fmt.Sprintf("%s is required", strings.ToLower(f.Field()))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s failed %s validation", strings.ToLower(f.Field()), f.Tag())
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) handleError(c *gin.Context, err error) {
|
func (h *HTTPHandler) handleError(c *gin.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, io.EOF):
|
case errors.Is(err, io.EOF):
|
||||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
|
||||||
|
case errors.Is(err, pgx.ErrNoRows):
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "resource not found")
|
||||||
default:
|
default:
|
||||||
|
var inputErr *services.InputError
|
||||||
|
if errors.As(err, &inputErr) {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", inputErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var valErrs validator.ValidationErrors
|
||||||
|
if errors.As(err, &valErrs) && len(valErrs) > 0 {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", formatFieldError(valErrs[0]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
// Structured codes — never leak raw SQLSTATE text or constraint names.
|
||||||
|
switch pgErr.Code {
|
||||||
|
case "23505":
|
||||||
|
apperrors.Abort(c, http.StatusConflict, "conflict", "resource already exists")
|
||||||
|
case "23503":
|
||||||
|
apperrors.Abort(c, http.StatusConflict, "conflict", "referenced resource does not exist")
|
||||||
|
case "22001", "54000":
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "value too large")
|
||||||
|
case "22021":
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "input is not valid UTF-8")
|
||||||
|
default:
|
||||||
|
apperrors.Abort(c, http.StatusInternalServerError, "request_failed", "database error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
message := strings.ToLower(err.Error())
|
message := strings.ToLower(err.Error())
|
||||||
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
|
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
|
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
@@ -104,4 +109,46 @@ func TestHandleErrorStatusMapping(t *testing.T) {
|
|||||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("maps missing rows to not found", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, recorder := newTestContext("")
|
||||||
|
handler.handleError(ctx, pgx.ErrNoRows)
|
||||||
|
if recorder.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("maps InputError to bad request with message", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, recorder := newTestContext("")
|
||||||
|
handler.handleError(ctx, services.NewInputError("webhook url must be https"))
|
||||||
|
if recorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "webhook url must be https") {
|
||||||
|
t.Fatalf("expected reason in body, got %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("maps unique violation to conflict without leaking", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, recorder := newTestContext("")
|
||||||
|
handler.handleError(ctx, &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint \"projects_slug_key\""})
|
||||||
|
if recorder.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(recorder.Body.String(), "projects_slug_key") || strings.Contains(recorder.Body.String(), "23505") {
|
||||||
|
t.Fatalf("raw constraint leaked: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("maps unknown pg error to internal error", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, recorder := newTestContext("")
|
||||||
|
handler.handleError(ctx, &pgconn.PgError{Code: "XX000", Message: "internal"})
|
||||||
|
if recorder.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -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 {
|
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
|
||||||
t.Error("expected unknown event rejected")
|
t.Error("expected unknown event rejected")
|
||||||
}
|
}
|
||||||
|
for _, ok := range [][]string{
|
||||||
|
{"*"},
|
||||||
|
{"document.*"},
|
||||||
|
{"custom.deploy.done"},
|
||||||
|
{"custom.*"},
|
||||||
|
{"custom.deploy.*"},
|
||||||
|
} {
|
||||||
|
if _, err := validateWebhookEvents(ok); err != nil {
|
||||||
|
t.Errorf("expected %v accepted, got %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range [][]string{
|
||||||
|
{"banana.*"}, {"custom..*"}, {"custom.UPPER"}, {"document.exploded"},
|
||||||
|
} {
|
||||||
|
if _, err := validateWebhookEvents(bad); err == nil {
|
||||||
|
t.Errorf("expected %v rejected", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivateWebhookHostHardening(t *testing.T) {
|
||||||
|
private := []string{"localhost", "minio", "echo-svc", "host.docker.internal",
|
||||||
|
"10.0.0.4", "172.16.5.5", "192.168.1.10", "fe80::1", "svc.local", "db.internal"}
|
||||||
|
public := []string{"169.254.169.254", "fd00:ec2::254", "2130706433", "0x7f000001",
|
||||||
|
"8.8.8.8", "example.com", "999999999999"}
|
||||||
|
for _, h := range private {
|
||||||
|
if !isPrivateWebhookHost(h) {
|
||||||
|
t.Errorf("expected %q private", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, h := range public {
|
||||||
|
if isPrivateWebhookHost(h) {
|
||||||
|
t.Errorf("expected %q public/blocked", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
|
|
||||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
@@ -34,6 +35,7 @@ type ScheduledJobSummary struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Schedule string `json:"schedule"`
|
Schedule string `json:"schedule"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
FunctionID *string `json:"function_id,omitempty"`
|
||||||
Payload json.RawMessage `json:"payload"`
|
Payload json.RawMessage `json:"payload"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
HasSecret bool `json:"has_secret"`
|
HasSecret bool `json:"has_secret"`
|
||||||
@@ -60,7 +62,8 @@ type ScheduledJobRunSummary struct {
|
|||||||
type CreateScheduledJobInput struct {
|
type CreateScheduledJobInput struct {
|
||||||
Name string `json:"name" validate:"required,min=2"`
|
Name string `json:"name" validate:"required,min=2"`
|
||||||
Schedule string `json:"schedule" validate:"required"`
|
Schedule string `json:"schedule" validate:"required"`
|
||||||
URL string `json:"url" validate:"required,url"`
|
URL string `json:"url" validate:"required_without=FunctionID,omitempty,url"`
|
||||||
|
FunctionID string `json:"function_id" validate:"omitempty,uuid"`
|
||||||
Secret string `json:"secret"`
|
Secret string `json:"secret"`
|
||||||
Payload map[string]any `json:"payload"`
|
Payload map[string]any `json:"payload"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
@@ -70,6 +73,7 @@ type UpdateScheduledJobInput struct {
|
|||||||
Name *string `json:"name" validate:"omitempty,min=2"`
|
Name *string `json:"name" validate:"omitempty,min=2"`
|
||||||
Schedule *string `json:"schedule"`
|
Schedule *string `json:"schedule"`
|
||||||
URL *string `json:"url" validate:"omitempty,url"`
|
URL *string `json:"url" validate:"omitempty,url"`
|
||||||
|
FunctionID *string `json:"function_id" validate:"omitempty,uuid"`
|
||||||
Secret *string `json:"secret"`
|
Secret *string `json:"secret"`
|
||||||
Payload map[string]any `json:"payload"`
|
Payload map[string]any `json:"payload"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
@@ -87,6 +91,10 @@ func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
|
|||||||
HasSecret: len(row.Secret) > 0,
|
HasSecret: len(row.Secret) > 0,
|
||||||
CreatedAt: row.CreatedAt.Time,
|
CreatedAt: row.CreatedAt.Time,
|
||||||
}
|
}
|
||||||
|
if row.FunctionID.Valid {
|
||||||
|
id := uuid.UUID(row.FunctionID.Bytes).String()
|
||||||
|
out.FunctionID = &id
|
||||||
|
}
|
||||||
if row.LastRunAt.Valid {
|
if row.LastRunAt.Valid {
|
||||||
t := row.LastRunAt.Time
|
t := row.LastRunAt.Time
|
||||||
out.LastRunAt = &t
|
out.LastRunAt = &t
|
||||||
@@ -175,10 +183,26 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
|
|||||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
return ScheduledJobSummary{}, err
|
return ScheduledJobSummary{}, err
|
||||||
}
|
}
|
||||||
jobURL, err := normalizeWebhookURL(input.URL)
|
jobURL := ""
|
||||||
|
var functionID pgtype.UUID
|
||||||
|
if input.FunctionID != "" {
|
||||||
|
fid, err := uuid.Parse(input.FunctionID)
|
||||||
|
if err != nil {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
|
||||||
|
}
|
||||||
|
fn, err := s.repo.Queries().GetFunction(ctx, fid)
|
||||||
|
if err != nil || fn.ProjectID != projectID {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
|
||||||
|
}
|
||||||
|
functionID = pgtype.UUID{Bytes: fid, Valid: true}
|
||||||
|
jobURL = "function://" + fid.String()
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
jobURL, err = normalizeWebhookURL(input.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ScheduledJobSummary{}, err
|
return ScheduledJobSummary{}, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
sched, err := parseSchedule(input.Schedule)
|
sched, err := parseSchedule(input.Schedule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ScheduledJobSummary{}, err
|
return ScheduledJobSummary{}, err
|
||||||
@@ -221,6 +245,7 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
|
|||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
NextRunAt: next,
|
NextRunAt: next,
|
||||||
CreatedByUserID: createdBy,
|
CreatedByUserID: createdBy,
|
||||||
|
FunctionID: functionID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
||||||
@@ -259,6 +284,28 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
|
|||||||
return ScheduledJobSummary{}, err
|
return ScheduledJobSummary{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
rotateFunction := input.FunctionID != nil
|
||||||
|
functionID := row.FunctionID
|
||||||
|
if rotateFunction {
|
||||||
|
raw := strings.TrimSpace(*input.FunctionID)
|
||||||
|
if raw == "" {
|
||||||
|
functionID = pgtype.UUID{} // explicit clear → back to URL delivery
|
||||||
|
if input.URL == nil && strings.HasPrefix(jobURL, "function://") {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("clearing function_id requires a url")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fid, ferr := uuid.Parse(raw)
|
||||||
|
if ferr != nil {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
|
||||||
|
}
|
||||||
|
fn, ferr := s.repo.Queries().GetFunction(ctx, fid)
|
||||||
|
if ferr != nil || fn.ProjectID != projectID {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
|
||||||
|
}
|
||||||
|
functionID = pgtype.UUID{Bytes: fid, Valid: true}
|
||||||
|
jobURL = "function://" + fid.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
enabled := row.Enabled
|
enabled := row.Enabled
|
||||||
if input.Enabled != nil {
|
if input.Enabled != nil {
|
||||||
enabled = *input.Enabled
|
enabled = *input.Enabled
|
||||||
@@ -301,6 +348,8 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
|
|||||||
Secret: secretBytes,
|
Secret: secretBytes,
|
||||||
Column10: rotatePayload,
|
Column10: rotatePayload,
|
||||||
Payload: payload,
|
Payload: payload,
|
||||||
|
Column12: rotateFunction,
|
||||||
|
FunctionID: functionID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
||||||
@@ -406,8 +455,13 @@ type JobScheduler struct {
|
|||||||
quit chan struct{}
|
quit chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
started sync.Once
|
started sync.Once
|
||||||
|
leader *leaderState
|
||||||
// onEvent fans a finished run out to webhooks and the realtime stream.
|
// onEvent fans a finished run out to webhooks and the realtime stream.
|
||||||
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
|
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
|
||||||
|
// runFn delivers to a function instead of HTTP when job.FunctionID is set.
|
||||||
|
// The function receives the resolved job payload, not the run envelope.
|
||||||
|
// Owned by PlatformService — the scheduler stays transport-agnostic.
|
||||||
|
runFn func(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type jobExec struct {
|
type jobExec struct {
|
||||||
@@ -415,7 +469,7 @@ type jobExec struct {
|
|||||||
run db.CoreScheduledJobRun
|
run db.CoreScheduledJobRun
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)) *JobScheduler {
|
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), pool *pgxpool.Pool) *JobScheduler {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
}
|
}
|
||||||
@@ -428,6 +482,7 @@ func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger,
|
|||||||
queue: make(chan jobExec, 256),
|
queue: make(chan jobExec, 256),
|
||||||
quit: make(chan struct{}),
|
quit: make(chan struct{}),
|
||||||
onEvent: onEvent,
|
onEvent: onEvent,
|
||||||
|
leader: newLeaderState(pool, leaderKeyScheduler),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,8 +507,13 @@ func (j *JobScheduler) run(ctx context.Context) {
|
|||||||
case exec := <-j.queue:
|
case exec := <-j.queue:
|
||||||
j.execute(exec.job, exec.run)
|
j.execute(exec.job, exec.run)
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
// Only the leader fires scheduled runs — manual/hook enqueues are
|
||||||
|
// already scoped to whichever replica received the request.
|
||||||
|
if j.leader.hold(ctx) {
|
||||||
j.scan()
|
j.scan()
|
||||||
|
}
|
||||||
case <-j.quit:
|
case <-j.quit:
|
||||||
|
j.leader.stop(context.Background())
|
||||||
return
|
return
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
@@ -551,7 +611,8 @@ func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, sta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deliver POSTs the job payload to its URL, signed like a webhook delivery.
|
// deliver POSTs the job payload to its URL — or invokes the target function
|
||||||
|
// when the job carries a function_id.
|
||||||
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
|
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
|
||||||
data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload)
|
data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -567,6 +628,12 @@ func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run
|
|||||||
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
"data": data,
|
"data": data,
|
||||||
})
|
})
|
||||||
|
if job.FunctionID.Valid {
|
||||||
|
if j.runFn == nil {
|
||||||
|
return nil, fmt.Errorf("functions runtime not configured")
|
||||||
|
}
|
||||||
|
return j.runFn(ctx, job, run, data)
|
||||||
|
}
|
||||||
secret := ""
|
secret := ""
|
||||||
if len(job.Secret) > 0 && j.enc != nil {
|
if len(job.Secret) > 0 && j.enc != nil {
|
||||||
if plain, err := j.enc.Decrypt(job.Secret); err == nil {
|
if plain, err := j.enc.Decrypt(job.Secret); err == nil {
|
||||||
|
|||||||
@@ -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"
|
"github.com/tdvorak/primora/apps/backend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Mailer is transport-only: Resend when configured, plain SMTP otherwise.
|
||||||
|
// Templates render (subject, body) from data; sends are logged to
|
||||||
|
// core.email_log by the service layer so operators can see what went out.
|
||||||
type Mailer struct {
|
type Mailer struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
resend *resend.Client
|
resend *resend.Client
|
||||||
@@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer {
|
|||||||
return &Mailer{cfg: cfg, resend: resendClient}
|
return &Mailer{cfg: cfg, resend: resendClient}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error {
|
// mailTemplate renders a subject + plaintext body from template data.
|
||||||
subject := fmt.Sprintf("You were invited to %s on Primora", organizationName)
|
type mailTemplate func(data map[string]string) (subject string, body string)
|
||||||
text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n"
|
|
||||||
|
var mailTemplates = map[string]mailTemplate{
|
||||||
|
"invitation": func(d map[string]string) (string, string) {
|
||||||
|
subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"])
|
||||||
|
body := fmt.Sprintf(
|
||||||
|
"You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n",
|
||||||
|
d["invite_url"],
|
||||||
|
)
|
||||||
|
return subject, body
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMailTemplate(template string, data map[string]string) (string, string, error) {
|
||||||
|
tpl, ok := mailTemplates[template]
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("unknown email template %q", template)
|
||||||
|
}
|
||||||
|
subject, body := tpl(data)
|
||||||
|
return subject, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error {
|
||||||
if m.resend != nil {
|
if m.resend != nil {
|
||||||
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
||||||
From: m.cfg.MailFrom,
|
From: m.cfg.MailFrom,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderMailTemplateInvitation(t *testing.T) {
|
||||||
|
subject, body, err := renderMailTemplate("invitation", map[string]string{
|
||||||
|
"organization": "Acme Corp",
|
||||||
|
"invite_url": "https://app.primora.dev/accept?token=abc",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "Acme Corp") {
|
||||||
|
t.Fatalf("subject missing organization: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "https://app.primora.dev/accept?token=abc") {
|
||||||
|
t.Fatalf("body missing invite url: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMailTemplateUnknown(t *testing.T) {
|
||||||
|
if _, _, err := renderMailTemplate("does-not-exist", nil); err == nil {
|
||||||
|
t.Fatal("expected error for unknown template")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractEmail(t *testing.T) {
|
||||||
|
if got := extractEmail("Primora <[email protected]>"); got != "[email protected]" {
|
||||||
|
t.Fatalf("extractEmail = %q", got)
|
||||||
|
}
|
||||||
|
if got := extractEmail("[email protected]"); got != "[email protected]" {
|
||||||
|
t.Fatalf("extractEmail bare = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -29,7 +32,7 @@ import (
|
|||||||
|
|
||||||
type PlatformService struct {
|
type PlatformService struct {
|
||||||
repo *repositories.CoreRepository
|
repo *repositories.CoreRepository
|
||||||
store *storage.LocalStore
|
store storage.Store
|
||||||
mailer *Mailer
|
mailer *Mailer
|
||||||
publicURL string
|
publicURL string
|
||||||
dbx *dbx.Client
|
dbx *dbx.Client
|
||||||
@@ -38,6 +41,13 @@ type PlatformService struct {
|
|||||||
enc *secrets.Encryptor
|
enc *secrets.Encryptor
|
||||||
dispatcher *WebhookDispatcher
|
dispatcher *WebhookDispatcher
|
||||||
scheduler *JobScheduler
|
scheduler *JobScheduler
|
||||||
|
alerts *alertEvaluator
|
||||||
|
functions FunctionRunner
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
// presenceMu guards presence: realtime subscriber counts per project.
|
||||||
|
presenceMu sync.Mutex
|
||||||
|
presence map[uuid.UUID]int
|
||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapInput struct {
|
type BootstrapInput struct {
|
||||||
@@ -223,17 +233,84 @@ type InvitationSummary struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
|
func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *PlatformService {
|
||||||
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc}
|
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions, logger: logger, presence: map[uuid.UUID]int{}}
|
||||||
if enc != nil {
|
if enc != nil {
|
||||||
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
||||||
s.dispatcher.Start(context.Background())
|
s.dispatcher.Start(context.Background())
|
||||||
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent)
|
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent, repo.Pool())
|
||||||
|
s.scheduler.runFn = s.runJobFunction
|
||||||
s.scheduler.Start(context.Background())
|
s.scheduler.Start(context.Background())
|
||||||
|
s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool())
|
||||||
|
s.alerts.Start(context.Background())
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendTemplatedEmail renders a registered template, sends it, and writes an
|
||||||
|
// email_log row with the outcome — failed sends are still logged so the
|
||||||
|
// dashboard shows them.
|
||||||
|
func (s *PlatformService) sendTemplatedEmail(ctx context.Context, projectID *uuid.UUID, template, to string, data map[string]string) error {
|
||||||
|
subject, body, err := renderMailTemplate(template, data)
|
||||||
|
status, errText := "sent", ""
|
||||||
|
if err == nil {
|
||||||
|
if sendErr := s.mailer.Send(ctx, to, subject, body); sendErr != nil {
|
||||||
|
status, errText, err = "failed", sendErr.Error(), sendErr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status, errText = "failed", err.Error()
|
||||||
|
}
|
||||||
|
var pid pgtype.UUID
|
||||||
|
if projectID != nil {
|
||||||
|
pid = pgtype.UUID{Bytes: *projectID, Valid: true}
|
||||||
|
}
|
||||||
|
_, _ = s.repo.Queries().InsertEmailLog(ctx, db.InsertEmailLogParams{
|
||||||
|
ProjectID: pid,
|
||||||
|
Template: template,
|
||||||
|
ToEmail: to,
|
||||||
|
Subject: subject,
|
||||||
|
Status: status,
|
||||||
|
Error: errText,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailLogSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
ToEmail string `json:"to_email"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListEmailLog(ctx context.Context, actor *models.Actor, projectID uuid.UUID, limit int64) ([]EmailLogSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListEmailLog(ctx, db.ListEmailLogParams{ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, Limit: int32(limit)})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]EmailLogSummary, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = EmailLogSummary{
|
||||||
|
ID: r.ID.String(),
|
||||||
|
Template: r.Template,
|
||||||
|
ToEmail: r.ToEmail,
|
||||||
|
Subject: r.Subject,
|
||||||
|
Status: r.Status,
|
||||||
|
Error: r.Error,
|
||||||
|
CreatedAt: r.CreatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Close stops the webhook dispatcher and job scheduler. Idempotent.
|
// Close stops the webhook dispatcher and job scheduler. Idempotent.
|
||||||
func (s *PlatformService) Close() {
|
func (s *PlatformService) Close() {
|
||||||
if s.dispatcher != nil {
|
if s.dispatcher != nil {
|
||||||
@@ -242,6 +319,9 @@ func (s *PlatformService) Close() {
|
|||||||
if s.scheduler != nil {
|
if s.scheduler != nil {
|
||||||
s.scheduler.Stop()
|
s.scheduler.Stop()
|
||||||
}
|
}
|
||||||
|
if s.alerts != nil {
|
||||||
|
s.alerts.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishEvent fans a domain event out to realtime SSE subscribers and to
|
// publishEvent fans a domain event out to realtime SSE subscribers and to
|
||||||
@@ -256,18 +336,118 @@ func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
s.dispatchEvent(ctx, projectID, eventType, data)
|
s.dispatchEvent(ctx, projectID, eventType, data)
|
||||||
|
s.triggerFunctionsForEvent(ctx, projectID, eventType, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscribeRealtime registers an SSE consumer for project domain events.
|
// SubscribeRealtime registers an SSE consumer for project domain events and
|
||||||
|
// bumps the project's presence count.
|
||||||
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
|
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
|
||||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.realtime.Subscribe(projectID.String()), nil
|
ch := s.realtime.Subscribe(projectID.String())
|
||||||
|
s.bumpPresence(projectID, 1)
|
||||||
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
|
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
|
||||||
s.realtime.Unsubscribe(projectID.String(), ch)
|
s.realtime.Unsubscribe(projectID.String(), ch)
|
||||||
|
s.bumpPresence(projectID, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bumpPresence adjusts the online count and broadcasts presence.update to
|
||||||
|
// realtime subscribers only — deliberately not through publishEvent, so
|
||||||
|
// webhooks and `event_pattern: "*"` functions don't fire on connect noise.
|
||||||
|
func (s *PlatformService) bumpPresence(projectID uuid.UUID, delta int) {
|
||||||
|
s.presenceMu.Lock()
|
||||||
|
n := s.presence[projectID] + delta
|
||||||
|
if n <= 0 {
|
||||||
|
delete(s.presence, projectID)
|
||||||
|
n = 0
|
||||||
|
} else {
|
||||||
|
s.presence[projectID] = n
|
||||||
|
}
|
||||||
|
s.presenceMu.Unlock()
|
||||||
|
s.realtime.Broadcast(projectID.String(), map[string]any{
|
||||||
|
"type": "presence.update",
|
||||||
|
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"data": map[string]any{"online": n},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presence returns the current realtime subscriber count for a project.
|
||||||
|
func (s *PlatformService) Presence(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (int, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
s.presenceMu.Lock()
|
||||||
|
defer s.presenceMu.Unlock()
|
||||||
|
return s.presence[projectID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var customEventTypeRe = regexp.MustCompile(`^custom\.[a-z0-9][a-z0-9_.-]{0,62}$`)
|
||||||
|
|
||||||
|
// PublishProjectEvent lets a member emit a custom domain event — realtime
|
||||||
|
// subscribers, matching webhooks, and event_pattern functions all fire via
|
||||||
|
// the standard publishEvent fan-out. The custom.* prefix is enforced so
|
||||||
|
// clients can't spoof system event types (document.created etc.).
|
||||||
|
func (s *PlatformService) PublishProjectEvent(ctx context.Context, actor *models.Actor, projectID uuid.UUID, eventType string, data map[string]any) error {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !customEventTypeRe.MatchString(eventType) {
|
||||||
|
return errors.New("invalid event type: must match custom.<name> (lowercase letters, digits, dots, dashes)")
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
data = map[string]any{}
|
||||||
|
}
|
||||||
|
s.publishEvent(ctx, projectID, eventType, data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActorContext describes the caller's own scope — the self-discovery
|
||||||
|
// endpoint for API keys, which cannot call /me. User actors get just the
|
||||||
|
// type marker; their full view is PlatformSummary.
|
||||||
|
type ActorContext struct {
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
KeyPrefix string `json:"key_prefix,omitempty"`
|
||||||
|
Organization *ContextOrgInfo `json:"organization,omitempty"`
|
||||||
|
Project *ContextRef `json:"project,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextOrgInfo struct {
|
||||||
|
ContextRef
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextRef struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ActorContext(ctx context.Context, actor *models.Actor) (ActorContext, error) {
|
||||||
|
out := ActorContext{Actor: string(actor.Type)}
|
||||||
|
if actor.Type != models.ActorTypeAPIKey {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
out.Scopes = actor.Scopes
|
||||||
|
out.KeyPrefix = actor.APIKeyPrefix
|
||||||
|
if actor.ProjectID == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
project, err := s.repo.Queries().GetProjectByID(ctx, *actor.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Project = &ContextRef{ID: project.ID, Slug: project.Slug, Name: project.Name}
|
||||||
|
org, err := s.repo.Queries().GetOrganizationByID(ctx, project.OrganizationID)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Organization = &ContextOrgInfo{ContextRef: ContextRef{ID: org.ID, Slug: org.Slug, Name: org.Name}}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
|
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
|
||||||
@@ -907,7 +1087,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
|
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
|
||||||
}
|
}
|
||||||
if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil {
|
var inviteProject *uuid.UUID
|
||||||
|
if projectUUID.Valid {
|
||||||
|
id := uuid.UUID(projectUUID.Bytes)
|
||||||
|
inviteProject = &id
|
||||||
|
}
|
||||||
|
if err := s.sendTemplatedEmail(ctx, inviteProject, "invitation", invitation.Email, map[string]string{
|
||||||
|
"organization": "Primora",
|
||||||
|
"invite_url": inviteURL,
|
||||||
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
@@ -1336,6 +1524,64 @@ func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bu
|
|||||||
return object, file, nil
|
return object, file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrPresignUnsupported — the configured storage driver can't mint signed
|
||||||
|
// URLs (only the S3 driver can). Handlers map it to 400, not 500.
|
||||||
|
var ErrPresignUnsupported = errors.New("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
|
||||||
|
|
||||||
|
// InputError marks a service-layer failure caused by caller input — the
|
||||||
|
// HTTP layer maps it to 400 instead of 500.
|
||||||
|
type InputError struct{ msg string }
|
||||||
|
|
||||||
|
func (e *InputError) Error() string { return e.msg }
|
||||||
|
|
||||||
|
func NewInputError(msg string) error { return &InputError{msg: msg} }
|
||||||
|
|
||||||
|
func inputErrorf(format string, args ...any) error {
|
||||||
|
return &InputError{msg: fmt.Sprintf(format, args...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignObject mints a short-lived direct-to-storage URL for a single
|
||||||
|
// object. Requires the s3 storage driver — the local store has no external
|
||||||
|
// URL surface to sign against.
|
||||||
|
func (s *PlatformService) PresignObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, op string, ttlSeconds int) (*storage.PresignedURL, error) {
|
||||||
|
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
method := ""
|
||||||
|
switch op {
|
||||||
|
case "download", "":
|
||||||
|
method = http.MethodGet
|
||||||
|
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !objectExists(ctx, s, bucketID, objectKey) {
|
||||||
|
return nil, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
case "upload":
|
||||||
|
method = http.MethodPut
|
||||||
|
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid op %q — download or upload", op)
|
||||||
|
}
|
||||||
|
p, ok := s.store.(storage.Presigner)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrPresignUnsupported
|
||||||
|
}
|
||||||
|
ttl := time.Duration(ttlSeconds) * time.Second
|
||||||
|
return p.Presign(ctx, bucketID.String(), objectKey, method, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func objectExists(ctx context.Context, s *PlatformService, bucketID uuid.UUID, key string) bool {
|
||||||
|
_, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
|
||||||
|
BucketID: bucketID,
|
||||||
|
ObjectKey: key,
|
||||||
|
})
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
|
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
|
||||||
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -31,6 +32,8 @@ const (
|
|||||||
WebhookEventIssueCreated = "issue.created"
|
WebhookEventIssueCreated = "issue.created"
|
||||||
WebhookEventDeployMarker = "deploy.marker"
|
WebhookEventDeployMarker = "deploy.marker"
|
||||||
WebhookEventTest = "webhook.test"
|
WebhookEventTest = "webhook.test"
|
||||||
|
WebhookEventAlertFired = "alert.fired"
|
||||||
|
WebhookEventAlertResolved = "alert.resolved"
|
||||||
WebhookEventDocumentCreated = "document.created"
|
WebhookEventDocumentCreated = "document.created"
|
||||||
WebhookEventDocumentUpdated = "document.updated"
|
WebhookEventDocumentUpdated = "document.updated"
|
||||||
WebhookEventDocumentDeleted = "document.deleted"
|
WebhookEventDocumentDeleted = "document.deleted"
|
||||||
@@ -43,6 +46,9 @@ var webhookEventTypes = map[string]bool{
|
|||||||
WebhookEventIssueCreated: true,
|
WebhookEventIssueCreated: true,
|
||||||
WebhookEventDeployMarker: true,
|
WebhookEventDeployMarker: true,
|
||||||
WebhookEventTest: true,
|
WebhookEventTest: true,
|
||||||
|
WebhookEventAlertFired: true,
|
||||||
|
WebhookEventAlertResolved: true,
|
||||||
|
WebhookEventInboundReceived: true,
|
||||||
WebhookEventJobRun: true,
|
WebhookEventJobRun: true,
|
||||||
WebhookEventDocumentCreated: true,
|
WebhookEventDocumentCreated: true,
|
||||||
WebhookEventDocumentUpdated: true,
|
WebhookEventDocumentUpdated: true,
|
||||||
@@ -160,7 +166,7 @@ func normalizeWebhookURL(raw string) (string, error) {
|
|||||||
trimmed := strings.TrimSpace(raw)
|
trimmed := strings.TrimSpace(raw)
|
||||||
u, err := url.Parse(trimmed)
|
u, err := url.Parse(trimmed)
|
||||||
if err != nil || u.Host == "" {
|
if err != nil || u.Host == "" {
|
||||||
return "", errors.New("webhook url must be an https URL")
|
return "", inputErrorf("webhook url must be an https URL")
|
||||||
}
|
}
|
||||||
if u.Scheme == "https" {
|
if u.Scheme == "https" {
|
||||||
return trimmed, nil
|
return trimmed, nil
|
||||||
@@ -168,17 +174,35 @@ func normalizeWebhookURL(raw string) (string, error) {
|
|||||||
if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) {
|
if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) {
|
||||||
return trimmed, nil
|
return trimmed, nil
|
||||||
}
|
}
|
||||||
return "", errors.New("webhook url must be https (http allowed only for private/self-hosted targets)")
|
return "", inputErrorf("webhook url must be https (http allowed only for private/self-hosted targets)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var decimalOrHexIPRe = regexp.MustCompile(`^(0x[0-9a-f]+|[0-9]+)$`)
|
||||||
|
|
||||||
|
// metadataAddrs are link-local addresses that serve cloud instance
|
||||||
|
// metadata — "private" by range but a privileged SSRF target.
|
||||||
|
var metadataAddrs = map[string]bool{
|
||||||
|
"169.254.169.254": true, // AWS/GCP/Azure
|
||||||
|
"fd00:ec2::254": true, // AWS IPv6 metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPrivateWebhookHost(host string) bool {
|
func isPrivateWebhookHost(host string) bool {
|
||||||
h := strings.ToLower(strings.Trim(host, "[]"))
|
h := strings.ToLower(strings.Trim(host, "[]"))
|
||||||
|
if metadataAddrs[h] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if h == "localhost" || h == "host.docker.internal" ||
|
if h == "localhost" || h == "host.docker.internal" ||
|
||||||
strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") {
|
strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if !strings.Contains(h, ".") && !strings.Contains(h, ":") {
|
if !strings.Contains(h, ".") && !strings.Contains(h, ":") {
|
||||||
return true // bare hostname — docker service name, unqualified LAN host
|
// Bare hostname — docker service name, unqualified LAN host. A
|
||||||
|
// pure decimal/hex integer is never a service name: it's a
|
||||||
|
// non-canonical IP encoding (2130706433 = 127.0.0.1, 0x7f000001).
|
||||||
|
if decimalOrHexIPRe.MatchString(h) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
ip := net.ParseIP(h)
|
ip := net.ParseIP(h)
|
||||||
if ip == nil {
|
if ip == nil {
|
||||||
@@ -187,16 +211,40 @@ func isPrivateWebhookHost(host string) bool {
|
|||||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// webhookEventNamespaces are the valid `<ns>.*` subscription prefixes —
|
||||||
|
// derived from the known system event types.
|
||||||
|
func webhookEventNamespaces() map[string]bool {
|
||||||
|
ns := map[string]bool{}
|
||||||
|
for t := range webhookEventTypes {
|
||||||
|
if i := strings.Index(t, "."); i > 0 {
|
||||||
|
ns[t[:i]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ns
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateWebhookEvents accepts known system types, `*`, `<ns>.*` namespace
|
||||||
|
// patterns, and `custom.*` wildcard patterns — the same grammar functions
|
||||||
|
// use for event_pattern. Matching happens via SQL LIKE at dispatch.
|
||||||
func validateWebhookEvents(events []string) ([]string, error) {
|
func validateWebhookEvents(events []string) ([]string, error) {
|
||||||
out := make([]string, 0, len(events))
|
out := make([]string, 0, len(events))
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
namespaces := webhookEventNamespaces()
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
e = strings.TrimSpace(e)
|
e = strings.TrimSpace(e)
|
||||||
if e == "" {
|
if e == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !webhookEventTypes[e] {
|
valid := webhookEventTypes[e] || e == "*"
|
||||||
return nil, fmt.Errorf("unknown event type %q", e)
|
if !valid && strings.HasSuffix(e, ".*") {
|
||||||
|
valid = namespaces[strings.TrimSuffix(e, ".*")]
|
||||||
|
}
|
||||||
|
if !valid && strings.HasPrefix(e, "custom.") {
|
||||||
|
// `*` isn't in the type grammar — probe it as a literal.
|
||||||
|
valid = customEventTypeRe.MatchString(strings.ReplaceAll(e, "*", "x"))
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return nil, inputErrorf("invalid event type %q — use a known type, <namespace>.*, custom.<pattern>, or *", e)
|
||||||
}
|
}
|
||||||
if !seen[e] {
|
if !seen[e] {
|
||||||
seen[e] = true
|
seen[e] = true
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (s *LocalStore) Put(ctx context.Context, bucketID, objectKey string, reader
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LocalStore) Open(bucketID, objectKey string) (*os.File, string, error) {
|
func (s *LocalStore) Open(bucketID, objectKey string) (io.ReadCloser, string, error) {
|
||||||
cleanKey, err := sanitizeObjectKey(objectKey)
|
cleanKey, err := sanitizeObjectKey(objectKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
|
|||||||
@@ -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"
|
$ref: "#/components/schemas/MeResponse"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/context:
|
||||||
|
get:
|
||||||
|
tags: [Platform]
|
||||||
|
operationId: getActorContext
|
||||||
|
summary: Caller scope — self-discovery for API keys, which cannot call /me
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Actor type, scopes, and resolved org/project for API keys
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ActorContext"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
/instance/public:
|
/instance/public:
|
||||||
get:
|
get:
|
||||||
tags: [Instance]
|
tags: [Instance]
|
||||||
@@ -677,6 +693,37 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/BucketObject"
|
$ref: "#/components/schemas/BucketObject"
|
||||||
|
/buckets/{bucketID}/object-presigns:
|
||||||
|
post:
|
||||||
|
tags: [Storage]
|
||||||
|
operationId: presignBucketObject
|
||||||
|
description: >-
|
||||||
|
Mint a short-lived URL the client calls against object storage
|
||||||
|
directly — keeps large transfers off the backend. Requires
|
||||||
|
BACKEND_STORAGE_DRIVER=s3; the URL's host comes from
|
||||||
|
S3_PUBLIC_ENDPOINT (or S3_ENDPOINT when unset). Signatures bind the
|
||||||
|
host — a presigned URL is valid only at the endpoint it was minted
|
||||||
|
for.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/BucketID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PresignObjectRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Presigned URL
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PresignedURL"
|
||||||
|
"404":
|
||||||
|
description: Bucket or object not found (download)
|
||||||
/buckets/{bucketID}:
|
/buckets/{bucketID}:
|
||||||
patch:
|
patch:
|
||||||
tags: [Storage]
|
tags: [Storage]
|
||||||
@@ -1300,6 +1347,37 @@ paths:
|
|||||||
$ref: "#/components/schemas/TelemetryEventListResponse"
|
$ref: "#/components/schemas/TelemetryEventListResponse"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
post:
|
||||||
|
tags: [Automation]
|
||||||
|
operationId: publishProjectEvent
|
||||||
|
summary: Publishes a client-authored domain event. Type must be in the custom.* namespace; the event fans out to realtime subscribers, matching webhooks, and event_pattern functions.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PublishEventRequest"
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: Event accepted for fan-out
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
published:
|
||||||
|
type: string
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"403":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
/projects/{projectID}/issues:
|
/projects/{projectID}/issues:
|
||||||
get:
|
get:
|
||||||
tags: [Telemetry]
|
tags: [Telemetry]
|
||||||
@@ -1856,6 +1934,28 @@ paths:
|
|||||||
description: text/event-stream of {type, occurred_at, data} messages
|
description: text/event-stream of {type, occurred_at, data} messages
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/realtime/presence:
|
||||||
|
get:
|
||||||
|
tags: [Automation]
|
||||||
|
operationId: getRealtimePresence
|
||||||
|
summary: Count of clients currently subscribed to the project's realtime stream. The stream also emits presence.update events on every join/leave.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Online subscriber count
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
online:
|
||||||
|
type: integer
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
/projects/{projectID}/secrets:
|
/projects/{projectID}/secrets:
|
||||||
get:
|
get:
|
||||||
tags: [Secrets]
|
tags: [Secrets]
|
||||||
@@ -1941,6 +2041,408 @@ paths:
|
|||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/alerts:
|
||||||
|
get:
|
||||||
|
tags: [Alerts]
|
||||||
|
operationId: listAlertRules
|
||||||
|
summary: Lists telemetry alert rules for the project.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Alert rules
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AlertRuleListResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
post:
|
||||||
|
tags: [Alerts]
|
||||||
|
operationId: createAlertRule
|
||||||
|
summary: Creates an alert rule. Rules fire `alert.fired`/`alert.resolved` domain events on state transitions — subscribe a webhook to those event types to receive them.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UpsertAlertRuleRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Alert rule created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AlertRule"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/alerts/{ruleID}:
|
||||||
|
put:
|
||||||
|
tags: [Alerts]
|
||||||
|
operationId: updateAlertRule
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: ruleID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UpsertAlertRuleRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Alert rule updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AlertRule"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
delete:
|
||||||
|
tags: [Alerts]
|
||||||
|
operationId: deleteAlertRule
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: ruleID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Alert rule deleted
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/inbound-hooks:
|
||||||
|
get:
|
||||||
|
tags: [Inbound]
|
||||||
|
operationId: listInboundHooks
|
||||||
|
summary: Lists inbound webhook endpoints for the project.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Inbound hooks
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/InboundHookListResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
post:
|
||||||
|
tags: [Inbound]
|
||||||
|
operationId: createInboundHook
|
||||||
|
summary: Creates an inbound hook. `event` mode republishes bodies as `inbound.received` domain events; `job` mode triggers a scheduled-job run with the body as its payload. Set `secret` to require `X-Primora-Signature` (HMAC-SHA256 over the raw body).
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateInboundHookRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Inbound hook created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/InboundHook"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/inbound-hooks/{hookID}:
|
||||||
|
delete:
|
||||||
|
tags: [Inbound]
|
||||||
|
operationId: deleteInboundHook
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: hookID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Inbound hook deleted
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/hooks/{token}:
|
||||||
|
post:
|
||||||
|
tags: [Inbound]
|
||||||
|
operationId: receiveInboundHook
|
||||||
|
summary: Public ingest endpoint — the URL token is the credential. Body must be JSON (max 256 KiB); sign with X-Primora-Signature when the hook has a secret.
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: token
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: X-Primora-Signature
|
||||||
|
in: header
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: Accepted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/InboundReceiveResponse"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/emails:
|
||||||
|
get:
|
||||||
|
tags: [Email]
|
||||||
|
operationId: listEmailLog
|
||||||
|
summary: Recent transactional emails sent for this project (invitations etc.) — every send is logged with its outcome.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
maximum: 200
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Email log entries
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/EmailLogListResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/functions:
|
||||||
|
get:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: listFunctions
|
||||||
|
summary: List functions in a project.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Functions
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FunctionListResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
post:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: createFunction
|
||||||
|
summary: Create a function (name, JS/TS code, runtime bun|deno).
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateFunctionRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Created function
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Function"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/functions/{functionID}:
|
||||||
|
get:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: getFunction
|
||||||
|
summary: Function metadata plus its source code.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: functionID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Function with code
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FunctionDetailResponse"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
put:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: updateFunction
|
||||||
|
summary: Update function code and/or enabled state.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: functionID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UpdateFunctionRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated function
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Function"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
delete:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: deleteFunction
|
||||||
|
summary: Delete a function and its run history.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: functionID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Deleted
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/functions/{functionID}/invoke:
|
||||||
|
post:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: invokeFunction
|
||||||
|
summary: Execute the function with a JSON payload on stdin; returns the recorded run. 502 when the run fails or times out.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: functionID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/InvokeFunctionRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Recorded run (status success)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FunctionRun"
|
||||||
|
"502":
|
||||||
|
description: Recorded run (status error|timeout)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FunctionRun"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/functions/{functionID}/runs:
|
||||||
|
get:
|
||||||
|
tags: [Functions]
|
||||||
|
operationId: listFunctionRuns
|
||||||
|
summary: Recent runs for a function.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- name: functionID
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
maximum: 200
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Function runs
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FunctionRunListResponse"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
@@ -2290,6 +2792,34 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/OrganizationSummary"
|
$ref: "#/components/schemas/OrganizationSummary"
|
||||||
required: [user, organizations]
|
required: [user, organizations]
|
||||||
|
ActorContext:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
actor:
|
||||||
|
type: string
|
||||||
|
enum: [user, api_key]
|
||||||
|
scopes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
key_prefix:
|
||||||
|
type: string
|
||||||
|
organization:
|
||||||
|
$ref: "#/components/schemas/ContextRef"
|
||||||
|
project:
|
||||||
|
$ref: "#/components/schemas/ContextRef"
|
||||||
|
required: [actor]
|
||||||
|
ContextRef:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
slug:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
required: [id, slug, name]
|
||||||
OrganizationMembership:
|
OrganizationMembership:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -2728,6 +3258,48 @@ components:
|
|||||||
format: uuid
|
format: uuid
|
||||||
nullable: true
|
nullable: true
|
||||||
required: [objectKey, newObjectKey]
|
required: [objectKey, newObjectKey]
|
||||||
|
PresignObjectRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
key:
|
||||||
|
type: string
|
||||||
|
description: Object key inside the bucket.
|
||||||
|
op:
|
||||||
|
type: string
|
||||||
|
enum: [download, upload]
|
||||||
|
default: download
|
||||||
|
description: download → GET URL (object must exist); upload → PUT URL (creates/overwrites).
|
||||||
|
ttl_seconds:
|
||||||
|
type: integer
|
||||||
|
minimum: 60
|
||||||
|
maximum: 3600
|
||||||
|
default: 900
|
||||||
|
required: [key]
|
||||||
|
PresignedURL:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
description: Fully-signed URL — call it with the returned method, no auth header needed.
|
||||||
|
method:
|
||||||
|
type: string
|
||||||
|
enum: [GET, PUT]
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
required: [url, method, expires_at]
|
||||||
|
PublishEventRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
pattern: '^custom\.[a-z0-9][a-z0-9_.-]{0,62}$'
|
||||||
|
description: Event type in the custom.* namespace — realtime subscribers, matching webhooks, and event_pattern functions all fire.
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
description: Arbitrary JSON payload carried to subscribers.
|
||||||
|
required: [type]
|
||||||
PaginationMetadata:
|
PaginationMetadata:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -3512,7 +4084,7 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Event filter (issue.created, deploy.marker, webhook.test, job.run, document.*, object.*). Empty means all events.
|
description: Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.*, object.*). Empty means all events.
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
has_secret:
|
has_secret:
|
||||||
@@ -3534,7 +4106,7 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
|
enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
required: [url]
|
required: [url]
|
||||||
@@ -3551,7 +4123,7 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
|
enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
WebhookCreateResponse:
|
WebhookCreateResponse:
|
||||||
@@ -3656,6 +4228,11 @@ components:
|
|||||||
description: Cron expression (5-field) or descriptor (@hourly, @every 30m).
|
description: Cron expression (5-field) or descriptor (@hourly, @every 30m).
|
||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
|
function_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
description: When set, the schedule invokes this function instead of POSTing url.
|
||||||
payload:
|
payload:
|
||||||
type: object
|
type: object
|
||||||
description: Static JSON payload merged into every delivery's data field.
|
description: Static JSON payload merged into every delivery's data field.
|
||||||
@@ -3691,6 +4268,11 @@ components:
|
|||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
format: uri
|
format: uri
|
||||||
|
description: Delivery target. Required unless function_id is set.
|
||||||
|
function_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Invoke this function on schedule instead of POSTing url.
|
||||||
secret:
|
secret:
|
||||||
type: string
|
type: string
|
||||||
description: HMAC key for X-Primora-Signature. Generated when omitted.
|
description: HMAC key for X-Primora-Signature. Generated when omitted.
|
||||||
@@ -3700,7 +4282,7 @@ components:
|
|||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
required: [name, schedule, url]
|
required: [name, schedule]
|
||||||
UpdateScheduledJobRequest:
|
UpdateScheduledJobRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -3712,6 +4294,11 @@ components:
|
|||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
format: uri
|
format: uri
|
||||||
|
function_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
nullable: true
|
||||||
|
description: Set to retarget at a function; send "" to clear back to url delivery.
|
||||||
secret:
|
secret:
|
||||||
type: string
|
type: string
|
||||||
description: Replace the signing secret. Omit to keep the current one.
|
description: Replace the signing secret. Omit to keep the current one.
|
||||||
@@ -3821,3 +4408,275 @@ components:
|
|||||||
value:
|
value:
|
||||||
type: string
|
type: string
|
||||||
required: [name, value]
|
required: [name, value]
|
||||||
|
AlertRuleConfig:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
component:
|
||||||
|
type: string
|
||||||
|
description: Restrict the rule to one component name; empty = all components.
|
||||||
|
threshold_minutes:
|
||||||
|
type: integer
|
||||||
|
description: heartbeat_silence — fire when a component has not heartbeated for this many minutes (1–10080).
|
||||||
|
threshold_count:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: error_spike — fire when a component logs at least this many errors inside the window.
|
||||||
|
window_minutes:
|
||||||
|
type: integer
|
||||||
|
description: error_spike — sliding window size (default 15, 1–1440).
|
||||||
|
AlertRule:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum: [heartbeat_silence, error_spike]
|
||||||
|
config:
|
||||||
|
$ref: "#/components/schemas/AlertRuleConfig"
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
firing:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Component names currently in breached state.
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
required: [id, project_id, name, kind, config, enabled, firing, created_at, updated_at]
|
||||||
|
AlertRuleListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AlertRule"
|
||||||
|
required: [items]
|
||||||
|
UpsertAlertRuleRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Env-var-safe name ([A-Za-z_][A-Za-z0-9_]*).
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum: [heartbeat_silence, error_spike]
|
||||||
|
config:
|
||||||
|
$ref: "#/components/schemas/AlertRuleConfig"
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
required: [name, kind]
|
||||||
|
InboundHook:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
description: Full ingest URL — POST here from the external system.
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum: [event, job, function]
|
||||||
|
job_id:
|
||||||
|
type: string
|
||||||
|
function_id:
|
||||||
|
type: string
|
||||||
|
has_secret:
|
||||||
|
type: boolean
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
last_received_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
required: [id, project_id, name, token, url, mode, has_secret, enabled, created_at]
|
||||||
|
InboundHookListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/InboundHook"
|
||||||
|
required: [items]
|
||||||
|
CreateInboundHookRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum: [event, job, function]
|
||||||
|
default: event
|
||||||
|
job_id:
|
||||||
|
type: string
|
||||||
|
description: Required when mode=job — scheduled job to trigger.
|
||||||
|
function_id:
|
||||||
|
type: string
|
||||||
|
description: Required when mode=function — invoked with the received body.
|
||||||
|
secret:
|
||||||
|
type: string
|
||||||
|
description: Optional — stored encrypted; callers must then sign bodies with X-Primora-Signature.
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
required: [name]
|
||||||
|
InboundReceiveResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
received:
|
||||||
|
type: boolean
|
||||||
|
event:
|
||||||
|
type: string
|
||||||
|
run_id:
|
||||||
|
type: string
|
||||||
|
required: [received]
|
||||||
|
EmailLogEntry:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
template:
|
||||||
|
type: string
|
||||||
|
to_email:
|
||||||
|
type: string
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [sent, failed]
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
required: [id, template, to_email, subject, status, created_at]
|
||||||
|
EmailLogListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/EmailLogEntry"
|
||||||
|
required: [items]
|
||||||
|
Function:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
runtime:
|
||||||
|
type: string
|
||||||
|
enum: [bun, deno]
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
event_pattern:
|
||||||
|
type: string
|
||||||
|
description: 'Domain-event pattern that triggers this function — "document.*", "*", exact type. Empty = never.'
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
required: [id, name, runtime, enabled, created_at, updated_at]
|
||||||
|
FunctionListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/Function"
|
||||||
|
required: [items]
|
||||||
|
FunctionDetailResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
function:
|
||||||
|
$ref: "#/components/schemas/Function"
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
required: [function, code]
|
||||||
|
CreateFunctionRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
runtime:
|
||||||
|
type: string
|
||||||
|
enum: [bun, deno]
|
||||||
|
default: bun
|
||||||
|
event_pattern:
|
||||||
|
type: string
|
||||||
|
description: 'Optional — "document.*", "*", or an exact event type.'
|
||||||
|
required: [name, code]
|
||||||
|
UpdateFunctionRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
event_pattern:
|
||||||
|
type: string
|
||||||
|
description: Replace the trigger pattern; "" disables event triggers.
|
||||||
|
InvokeFunctionRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
payload: {}
|
||||||
|
FunctionRun:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
trigger:
|
||||||
|
type: string
|
||||||
|
enum: [manual, schedule, hook, event]
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [success, error, timeout]
|
||||||
|
exit_code:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
stdout:
|
||||||
|
type: string
|
||||||
|
stderr:
|
||||||
|
type: string
|
||||||
|
duration_ms:
|
||||||
|
type: integer
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
required: [id, trigger, status, stdout, stderr, duration_ms, created_at]
|
||||||
|
FunctionRunListResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/FunctionRun"
|
||||||
|
required: [items]
|
||||||
+8
-1
@@ -21,7 +21,14 @@ project interactively.
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`,
|
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`,
|
||||||
`jobs`, `audit list --follow`. Every command accepts `--json`.
|
`jobs`, `events:send`, `audit list --follow`. Every command accepts `--json`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
primora events:send custom.deploy.done --data '{"sha":"abc123"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`events:send` publishes a `custom.*` event — realtime subscribers, matching
|
||||||
|
webhooks, and `event_pattern` functions all fire.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
primora documents list --collection users \
|
primora documents list --collection users \
|
||||||
|
|||||||
+79
-3
@@ -3,7 +3,8 @@ import { cac } from "cac";
|
|||||||
import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
|
import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
|
||||||
import { cmdAuditList } from "./commands/audit.js";
|
import { cmdAuditList } from "./commands/audit.js";
|
||||||
import { cmdDocumentsList } from "./commands/documents.js";
|
import { cmdDocumentsList } from "./commands/documents.js";
|
||||||
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
|
import { cmdEventsSend } from "./commands/events.js";
|
||||||
|
import { cmdBucketsCreate, cmdBucketsList, cmdBucketsRemove } from "./commands/buckets.js";
|
||||||
import { cmdContext, cmdUse } from "./commands/context.js";
|
import { cmdContext, cmdUse } from "./commands/context.js";
|
||||||
import { cmdInject } from "./commands/inject.js";
|
import { cmdInject } from "./commands/inject.js";
|
||||||
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
|
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
|
||||||
@@ -12,10 +13,11 @@ import {
|
|||||||
cmdObjectsDownload,
|
cmdObjectsDownload,
|
||||||
cmdObjectsList,
|
cmdObjectsList,
|
||||||
cmdObjectsRemove,
|
cmdObjectsRemove,
|
||||||
|
cmdObjectsPresign,
|
||||||
cmdObjectsUpload,
|
cmdObjectsUpload,
|
||||||
} from "./commands/objects.js";
|
} from "./commands/objects.js";
|
||||||
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
|
import { cmdOrgsCreate, cmdOrgsList, cmdOrgsRemove } from "./commands/orgs.js";
|
||||||
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js";
|
import { cmdProjectsCreate, cmdProjectsList, cmdProjectsRemove } from "./commands/projects.js";
|
||||||
import {
|
import {
|
||||||
cmdSecretsGet,
|
cmdSecretsGet,
|
||||||
cmdSecretsImport,
|
cmdSecretsImport,
|
||||||
@@ -50,6 +52,7 @@ cli
|
|||||||
.option("--email <email>", "Account email")
|
.option("--email <email>", "Account email")
|
||||||
.option("--password <password>", "Account password")
|
.option("--password <password>", "Account password")
|
||||||
.option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session")
|
.option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session")
|
||||||
|
.option("--json", "Machine-readable output")
|
||||||
.action(cmdLogin);
|
.action(cmdLogin);
|
||||||
|
|
||||||
cli.command("logout", "Remove stored credentials").action(cmdLogout);
|
cli.command("logout", "Remove stored credentials").action(cmdLogout);
|
||||||
@@ -68,6 +71,10 @@ cli
|
|||||||
.command("orgs:create <name>", "Create an organization")
|
.command("orgs:create <name>", "Create an organization")
|
||||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||||
.action(cmdOrgsCreate);
|
.action(cmdOrgsCreate);
|
||||||
|
cli
|
||||||
|
.command("orgs:rm <org>", "Delete an organization (id, slug, or name)")
|
||||||
|
.option("--yes", "Skip the confirmation prompt")
|
||||||
|
.action(cmdOrgsRemove);
|
||||||
|
|
||||||
cli
|
cli
|
||||||
.command("projects:list", "List projects in the current org")
|
.command("projects:list", "List projects in the current org")
|
||||||
@@ -80,6 +87,10 @@ cli
|
|||||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||||
.option("--description <text>", "Description")
|
.option("--description <text>", "Description")
|
||||||
.action(cmdProjectsCreate);
|
.action(cmdProjectsCreate);
|
||||||
|
cli
|
||||||
|
.command("projects:rm <project>", "Delete a project (id, slug, or name)")
|
||||||
|
.option("--yes", "Skip the confirmation prompt")
|
||||||
|
.action(cmdProjectsRemove);
|
||||||
|
|
||||||
cli
|
cli
|
||||||
.command("buckets:list", "List buckets in the current project")
|
.command("buckets:list", "List buckets in the current project")
|
||||||
@@ -92,6 +103,11 @@ cli
|
|||||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||||
.option("--public", "Public visibility (default private)")
|
.option("--public", "Public visibility (default private)")
|
||||||
.action(cmdBucketsCreate);
|
.action(cmdBucketsCreate);
|
||||||
|
cli
|
||||||
|
.command("buckets:rm <bucket>", "Delete a bucket and its objects (id or slug)")
|
||||||
|
.option("--project <id>", "Project override")
|
||||||
|
.option("--yes", "Skip the confirmation prompt")
|
||||||
|
.action(cmdBucketsRemove);
|
||||||
|
|
||||||
cli
|
cli
|
||||||
.command("objects:list <bucket>", "List objects in a bucket (id or slug)")
|
.command("objects:list <bucket>", "List objects in a bucket (id or slug)")
|
||||||
@@ -114,6 +130,12 @@ cli
|
|||||||
.command("objects:rm <bucket> <key>", "Delete an object")
|
.command("objects:rm <bucket> <key>", "Delete an object")
|
||||||
.option("--project <id>", "Project override")
|
.option("--project <id>", "Project override")
|
||||||
.action(cmdObjectsRemove);
|
.action(cmdObjectsRemove);
|
||||||
|
cli
|
||||||
|
.command("objects:presign <bucket> <key>", "Mint a presigned URL (s3 driver)")
|
||||||
|
.option("--project <id>", "Project override")
|
||||||
|
.option("--upload", "PUT URL for direct upload instead of GET download")
|
||||||
|
.option("--ttl <seconds>", "Lifetime 60-3600 (default 900)")
|
||||||
|
.action(cmdObjectsPresign);
|
||||||
|
|
||||||
cli
|
cli
|
||||||
.command("keys:list", "List API keys for the current project")
|
.command("keys:list", "List API keys for the current project")
|
||||||
@@ -167,6 +189,12 @@ cli
|
|||||||
.option("--offset <n>", "Page offset")
|
.option("--offset <n>", "Page offset")
|
||||||
.action(cmdDocumentsList);
|
.action(cmdDocumentsList);
|
||||||
|
|
||||||
|
cli
|
||||||
|
.command("events:send <type>", "Publish a custom.* event to realtime subscribers, webhooks, and functions")
|
||||||
|
.option("--project <id>", "Project override")
|
||||||
|
.option("--data <json>", "JSON object payload")
|
||||||
|
.action(cmdEventsSend);
|
||||||
|
|
||||||
cli
|
cli
|
||||||
.command("audit:list", "List audit events for the current project")
|
.command("audit:list", "List audit events for the current project")
|
||||||
.option("--project <id>", "Project override")
|
.option("--project <id>", "Project override")
|
||||||
@@ -317,9 +345,57 @@ if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
|
|||||||
argv[0] = "secrets:list";
|
argv[0] = "secrets:list";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mri coerces numeric-looking option values ("007" → 7, "" → 0), which
|
||||||
|
// corrupts secrets, keys, and passwords. Re-extract raw strings for every
|
||||||
|
// non-boolean flag and patch parsed options before the action runs.
|
||||||
|
function restoreRawOptionStrings(): void {
|
||||||
|
const cmd = (cli as unknown as { matchedCommand?: { options: { isBoolean: boolean; name: string; names: string[] }[] } })
|
||||||
|
.matchedCommand;
|
||||||
|
if (!cmd) return;
|
||||||
|
const state = cli as unknown as { options: Record<string, unknown>; globalCommand: { options: { isBoolean: boolean; name: string; names: string[] }[] } };
|
||||||
|
const specs = [...state.globalCommand.options, ...cmd.options].filter((o) => !o.isBoolean);
|
||||||
|
const raw = process.argv.slice(2);
|
||||||
|
const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||||
|
for (const spec of specs) {
|
||||||
|
if (typeof state.options[spec.name] !== "number") continue;
|
||||||
|
let value: string | undefined;
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
for (const n of spec.names) {
|
||||||
|
const flags = n.length === 1 ? [`-${n}`] : [`--${n}`, `--${kebab(n)}`];
|
||||||
|
for (const f of flags) {
|
||||||
|
if (raw[i] === f) value = raw[i + 1];
|
||||||
|
else if (raw[i].startsWith(f + "=")) value = raw[i].slice(f.length + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value !== undefined) state.options[spec.name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown command or a bare group — cac only fires this on non-empty args.
|
||||||
|
cli.addEventListener("command:*", () => {
|
||||||
|
const state = cli as unknown as { args: string[]; commands: { name: string }[] };
|
||||||
|
const arg = String(state.args[0] ?? "");
|
||||||
|
const group = arg.split(":")[0];
|
||||||
|
const verbs = GROUPS.has(group)
|
||||||
|
? state.commands.map((c) => c.name).filter((n) => n.startsWith(group + ":")).map((n) => n.slice(group.length + 1))
|
||||||
|
: [];
|
||||||
|
process.stderr.write(`error: unknown command "${arg.replace(":", " ")}"\n`);
|
||||||
|
if (verbs.length) process.stderr.write(` ${group} verbs: ${verbs.join(", ")}\n`);
|
||||||
|
process.stderr.write(" run `primora --help` for usage\n");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
cli.parse([process.argv[0], process.argv[1], ...argv], { run: false });
|
cli.parse([process.argv[0], process.argv[1], ...argv], { run: false });
|
||||||
|
restoreRawOptionStrings();
|
||||||
|
const state = cli as unknown as { matchedCommand?: unknown; options: Record<string, unknown> };
|
||||||
|
if (!state.matchedCommand && !state.options.help && !state.options.version) {
|
||||||
|
cli.outputHelp();
|
||||||
|
if (argv.length) process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await cli.runMatchedCommand();
|
await cli.runMatchedCommand();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(error);
|
fail(error);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface LoginOptions {
|
|||||||
email?: string;
|
email?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
json?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancel(): never {
|
function cancel(): never {
|
||||||
@@ -39,9 +40,22 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
|
|||||||
throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…");
|
throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…");
|
||||||
}
|
}
|
||||||
cfg.auth = { type: "apiKey", key: options.apiKey };
|
cfg.auth = { type: "apiKey", key: options.apiKey };
|
||||||
|
// Validate the key against the instance before saving — a stored-but-
|
||||||
|
// dead key is a worse failure than a rejected login.
|
||||||
|
configureClient(cfg);
|
||||||
|
const ctx = await PlatformService.getActorContext();
|
||||||
|
if (ctx.organization) cfg.organizationId = ctx.organization.id;
|
||||||
|
if (ctx.project) cfg.projectId = ctx.project.id;
|
||||||
saveConfig(cfg);
|
saveConfig(cfg);
|
||||||
success(`Saved API key credentials to ${configFilePath()}`);
|
if (isJson(options)) {
|
||||||
dim("API keys are project-scoped — set context with `primora use --org <id> --project <id>`.");
|
printJson(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
success(`API key verified — ${ctx.key_prefix}… (scopes: ${ctx.scopes?.join(", ") || "none"})`);
|
||||||
|
if (ctx.organization && ctx.project) {
|
||||||
|
success(`Context → ${ctx.organization.slug} / ${ctx.project.slug}`);
|
||||||
|
}
|
||||||
|
dim(`Config written to ${configFilePath()}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +108,10 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
|
|||||||
|
|
||||||
configureClient(cfg);
|
configureClient(cfg);
|
||||||
const me = await PlatformService.getMe();
|
const me = await PlatformService.getMe();
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson(me);
|
||||||
|
return;
|
||||||
|
}
|
||||||
success(`Signed in as ${me.user.email}`);
|
success(`Signed in as ${me.user.email}`);
|
||||||
dim(`Config written to ${configFilePath()}`);
|
dim(`Config written to ${configFilePath()}`);
|
||||||
}
|
}
|
||||||
@@ -108,6 +126,23 @@ export async function cmdLogout(): Promise<void> {
|
|||||||
export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
|
export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
configureClient(cfg);
|
configureClient(cfg);
|
||||||
|
if (cfg.auth?.type === "apiKey") {
|
||||||
|
const ctx = await PlatformService.getActorContext();
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(["KEY", "SCOPES", "ORGANIZATION", "PROJECT"], [
|
||||||
|
[
|
||||||
|
`${ctx.key_prefix}…`,
|
||||||
|
ctx.scopes?.join(", ") || "—",
|
||||||
|
ctx.organization?.slug ?? "—",
|
||||||
|
ctx.project?.slug ?? "—",
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
dim(`\n${cfg.baseUrl} · auth: api_key`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const me = await PlatformService.getMe();
|
const me = await PlatformService.getMe();
|
||||||
|
|
||||||
if (isJson(options)) {
|
if (isJson(options)) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { CreateBucketRequest, StorageService } from "@primora/api-client";
|
import { CreateBucketRequest, StorageService } from "@primora/api-client";
|
||||||
|
|
||||||
import { loadConfig } from "../config.js";
|
import { loadConfig } from "../config.js";
|
||||||
import { configureClient, requireAuth, requireProject } from "../http.js";
|
import { configureClient, requireAuth, requireProject, resolveBucketId } from "../http.js";
|
||||||
import { isJson, printJson, printTable, success } from "../out.js";
|
import { confirmOrAbort, isJson, printJson, printTable, success } from "../out.js";
|
||||||
|
|
||||||
export async function cmdBucketsList(options: {
|
export async function cmdBucketsList(options: {
|
||||||
project?: string;
|
project?: string;
|
||||||
@@ -54,3 +54,22 @@ export async function cmdBucketsCreate(
|
|||||||
}
|
}
|
||||||
success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`);
|
success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cmdBucketsRemove(
|
||||||
|
bucketRef: string,
|
||||||
|
options: { project?: string; yes?: boolean; json?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
requireAuth(cfg);
|
||||||
|
configureClient(cfg);
|
||||||
|
|
||||||
|
const projectId = requireProject(cfg, options.project);
|
||||||
|
const bucketId = await resolveBucketId(projectId, bucketRef);
|
||||||
|
await confirmOrAbort(`Delete bucket "${bucketRef}" and all its objects?`, options.yes);
|
||||||
|
await StorageService.deleteBucket({ bucketId });
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson({ deleted: bucketId });
|
||||||
|
} else {
|
||||||
|
success(`deleted bucket ${bucketRef}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,22 @@ export async function cmdUse(options: UseOptions): Promise<void> {
|
|||||||
requireAuth(cfg);
|
requireAuth(cfg);
|
||||||
configureClient(cfg);
|
configureClient(cfg);
|
||||||
|
|
||||||
|
// API keys are bound to one org+project — resolve the scope directly.
|
||||||
|
if (cfg.auth?.type === "apiKey") {
|
||||||
|
const ctx = await PlatformService.getActorContext();
|
||||||
|
if (options.org && ctx.organization && options.org !== ctx.organization.id && options.org !== ctx.organization.slug) {
|
||||||
|
throw new Error(`API key is scoped to ${ctx.organization.slug} — cannot switch to "${options.org}".`);
|
||||||
|
}
|
||||||
|
if (options.project && ctx.project && options.project !== ctx.project.id && options.project !== ctx.project.slug) {
|
||||||
|
throw new Error(`API key is scoped to ${ctx.project.slug} — cannot switch to "${options.project}".`);
|
||||||
|
}
|
||||||
|
if (ctx.organization) cfg.organizationId = ctx.organization.id;
|
||||||
|
if (ctx.project) cfg.projectId = ctx.project.id;
|
||||||
|
saveConfig(cfg);
|
||||||
|
success(`Context → ${ctx.organization?.slug ?? "—"} / ${ctx.project?.slug ?? "—"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const me = await PlatformService.getMe();
|
const me = await PlatformService.getMe();
|
||||||
const orgs = me.organizations;
|
const orgs = me.organizations;
|
||||||
if (orgs.length === 0) {
|
if (orgs.length === 0) {
|
||||||
@@ -77,6 +93,19 @@ export async function cmdContext(options: { json?: boolean }): Promise<void> {
|
|||||||
requireAuth(cfg);
|
requireAuth(cfg);
|
||||||
configureClient(cfg);
|
configureClient(cfg);
|
||||||
|
|
||||||
|
if (cfg.auth?.type === "apiKey") {
|
||||||
|
const ctx = await PlatformService.getActorContext();
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson({ baseUrl: cfg.baseUrl, auth: "apiKey", ...ctx });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dim(`url ${cfg.baseUrl}`);
|
||||||
|
dim(`auth api_key (${ctx.key_prefix}…, scopes: ${ctx.scopes?.join(", ") || "none"})`);
|
||||||
|
process.stdout.write(`org ${ctx.organization ? `${ctx.organization.name} (${ctx.organization.slug})` : "—"}\n`);
|
||||||
|
process.stdout.write(`project ${ctx.project ? `${ctx.project.name} (${ctx.project.slug})` : "—"}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const me = await PlatformService.getMe();
|
const me = await PlatformService.getMe();
|
||||||
const org = me.organizations.find((o) => o.id === cfg.organizationId);
|
const org = me.organizations.find((o) => o.id === cfg.organizationId);
|
||||||
const project = org?.projects.find((pr) => pr.id === cfg.projectId);
|
const project = org?.projects.find((pr) => pr.id === cfg.projectId);
|
||||||
|
|||||||
@@ -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 { readFile, writeFile } from "node:fs/promises";
|
||||||
import { basename } from "node:path";
|
import { basename } from "node:path";
|
||||||
|
|
||||||
import { StorageService } from "@primora/api-client";
|
import { PresignObjectRequest, StorageService } from "@primora/api-client";
|
||||||
|
|
||||||
import { loadConfig } from "../config.js";
|
import { loadConfig } from "../config.js";
|
||||||
import {
|
import {
|
||||||
@@ -133,3 +133,31 @@ export async function cmdObjectsRemove(
|
|||||||
await StorageService.deleteBucketObject({ bucketId, objectKey });
|
await StorageService.deleteBucketObject({ bucketId, objectKey });
|
||||||
success(`Deleted ${objectKey}`);
|
success(`Deleted ${objectKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cmdObjectsPresign(
|
||||||
|
bucketRef: string,
|
||||||
|
objectKey: string,
|
||||||
|
options: { project?: string; upload?: boolean; ttl?: string; json?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
requireAuth(cfg);
|
||||||
|
configureClient(cfg);
|
||||||
|
|
||||||
|
const bucketId = await bucketIdFor(cfg, bucketRef, options.project);
|
||||||
|
const presigned = await StorageService.presignBucketObject({
|
||||||
|
bucketId,
|
||||||
|
requestBody: {
|
||||||
|
key: objectKey,
|
||||||
|
op: options.upload
|
||||||
|
? PresignObjectRequest.op.UPLOAD
|
||||||
|
: PresignObjectRequest.op.DOWNLOAD,
|
||||||
|
ttl_seconds: options.ttl ? Number(options.ttl) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson(presigned);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(presigned.url);
|
||||||
|
console.error(`# ${presigned.method} — expires ${presigned.expires_at}`);
|
||||||
|
}
|
||||||
@@ -38,3 +38,31 @@ export async function cmdOrgsCreate(
|
|||||||
}
|
}
|
||||||
success(`Organization "${org.name}" created (${org.slug})`);
|
success(`Organization "${org.name}" created (${org.slug})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cmdOrgsRemove(
|
||||||
|
orgRef: string,
|
||||||
|
options: { yes?: boolean; json?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
requireAuth(cfg);
|
||||||
|
configureClient(cfg);
|
||||||
|
|
||||||
|
let organizationId = orgRef;
|
||||||
|
if (!isUuid(orgRef)) {
|
||||||
|
const me = await PlatformService.getMe();
|
||||||
|
const found = me.organizations.find((o) => o.id === orgRef || o.slug === orgRef || o.name === orgRef);
|
||||||
|
if (!found) throw new Error(`Organization "${orgRef}" not found — pass its id directly.`);
|
||||||
|
organizationId = found.id;
|
||||||
|
}
|
||||||
|
await confirmOrAbort(`Delete organization "${orgRef}"? Every project inside goes with it.`, options.yes);
|
||||||
|
await OrganizationsService.deleteOrganization({ organizationId });
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson({ deleted: organizationId });
|
||||||
|
} else {
|
||||||
|
success(`deleted organization ${orgRef}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUuid(v: string): boolean {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||||
|
}
|
||||||
@@ -50,3 +50,34 @@ export async function cmdProjectsCreate(
|
|||||||
}
|
}
|
||||||
success(`Project "${project.name}" created (${project.slug})`);
|
success(`Project "${project.name}" created (${project.slug})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cmdProjectsRemove(
|
||||||
|
projectRef: string,
|
||||||
|
options: { org?: string; yes?: boolean; json?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
requireAuth(cfg);
|
||||||
|
configureClient(cfg);
|
||||||
|
|
||||||
|
let projectId = projectRef;
|
||||||
|
if (!isUuid(projectRef)) {
|
||||||
|
const me = await PlatformService.getMe();
|
||||||
|
const orgs = cfg.auth?.type === "apiKey" ? [] : me.organizations;
|
||||||
|
const found = orgs
|
||||||
|
.flatMap((o) => o.projects.map((pr) => ({ ...pr, orgId: o.id })))
|
||||||
|
.find((pr) => pr.id === projectRef || pr.slug === projectRef || pr.name === projectRef);
|
||||||
|
if (!found) throw new Error(`Project "${projectRef}" not found — pass its id directly.`);
|
||||||
|
projectId = found.id;
|
||||||
|
}
|
||||||
|
await confirmOrAbort(`Delete project "${projectRef}"? Buckets, secrets, and functions go with it.`, options.yes);
|
||||||
|
await ProjectsService.deleteProject({ projectId });
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson({ deleted: projectId });
|
||||||
|
} else {
|
||||||
|
success(`deleted project ${projectRef}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUuid(v: string): boolean {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||||
|
}
|
||||||
@@ -60,6 +60,8 @@ export function configureClient(cfg: CliConfig): void {
|
|||||||
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
|
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
|
||||||
OpenAPI.HEADERS = undefined;
|
OpenAPI.HEADERS = undefined;
|
||||||
OpenAPI.TOKEN = undefined;
|
OpenAPI.TOKEN = undefined;
|
||||||
|
// encodeURI leaves ?#& intact in path params — smuggles query segments.
|
||||||
|
OpenAPI.ENCODE_PATH = encodeURIComponent;
|
||||||
|
|
||||||
if (cfg.auth?.type === "apiKey") {
|
if (cfg.auth?.type === "apiKey") {
|
||||||
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
|
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
|
||||||
|
|||||||
+19
-1
@@ -17,9 +17,27 @@ export function dim(message: string): void {
|
|||||||
process.stdout.write(pc.dim(message) + "\n");
|
process.stdout.write(pc.dim(message) + "\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Destructive-action gate — requires --yes in scripts, prompts on a TTY. */
|
||||||
|
export async function confirmOrAbort(message: string, yes?: boolean): Promise<void> {
|
||||||
|
if (yes) return;
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
throw new Error(`${message} — pass --yes to confirm in non-interactive mode`);
|
||||||
|
}
|
||||||
|
const answer = await p.confirm({ message });
|
||||||
|
if (p.isCancel(answer) || !answer) {
|
||||||
|
p.cancel("Aborted.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function fail(error: unknown): never {
|
export function fail(error: unknown): never {
|
||||||
const err = error as { message?: string; hint?: string; status?: number; body?: unknown };
|
const err = error as { message?: string; hint?: string; status?: number; body?: unknown; cause?: { code?: string } };
|
||||||
let message = err?.message ?? "unknown error";
|
let message = err?.message ?? "unknown error";
|
||||||
|
// undici TypeError "fetch failed" — the useful part is the cause code.
|
||||||
|
if (message === "fetch failed" || message.startsWith("fetch failed")) {
|
||||||
|
const code = err?.cause?.code;
|
||||||
|
message = `could not reach the instance${code ? ` (${code})` : ""} — check \`primora context\` and that it is running`;
|
||||||
|
}
|
||||||
// generated client throws ApiError with a parsed body
|
// generated client throws ApiError with a parsed body
|
||||||
const body = err?.body as { error?: { message?: string } } | undefined;
|
const body = err?.body as { error?: { message?: string } } | undefined;
|
||||||
if (body?.error?.message) message = body.error.message;
|
if (body?.error?.message) message = body.error.message;
|
||||||
|
|||||||
@@ -44,3 +44,21 @@ file, one crypto implementation — terminal and desktop always agree. Requires
|
|||||||
Injection and the credential broker stay CLI-only (`primora inject`,
|
Injection and the credential broker stay CLI-only (`primora inject`,
|
||||||
`primora agent`).
|
`primora agent`).
|
||||||
- Deep links / push notifications: deliberately omitted — nothing needs them yet.
|
- Deep links / push notifications: deliberately omitted — nothing needs them yet.
|
||||||
|
|
||||||
|
## Auto-updates
|
||||||
|
|
||||||
|
`tauri-plugin-updater` checks `releases/latest/download/latest.json` on the
|
||||||
|
GitHub repo when the connect screen loads; if a newer signed release exists
|
||||||
|
it offers "Update & restart" — download, minisign verification, install,
|
||||||
|
relaunch. No update manifest or signature failure = silent no-op; unsigned
|
||||||
|
artifacts can never install.
|
||||||
|
|
||||||
|
Release side (already wired in `.github/workflows/release.yml`): the desktop
|
||||||
|
job signs updater artifacts with `TAURI_SIGNING_PRIVATE_KEY` (+ optional
|
||||||
|
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`) repo secrets, uploads the `.sig`
|
||||||
|
files, and the release job assembles `latest.json` from them. Without the
|
||||||
|
secret the build still succeeds but emits no signatures — and no manifest.
|
||||||
|
|
||||||
|
The matching public key is embedded in `tauri.conf.json`. The private key
|
||||||
|
lives only in repo secrets and on the maintainer's machine — regenerate with
|
||||||
|
`npx tauri signer generate` and update both if it is ever lost.
|
||||||
Generated
+457
-8
@@ -47,6 +47,15 @@ version = "1.0.104"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||||
|
dependencies = [
|
||||||
|
"derive_arbitrary",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -506,6 +515,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -529,7 +548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -542,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -713,6 +732,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -996,6 +1026,16 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
@@ -1254,6 +1294,17 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -1530,6 +1581,21 @@ dependencies = [
|
|||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1548,9 +1614,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1847,6 +1915,36 @@ dependencies = [
|
|||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-macros",
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
"log",
|
||||||
|
"simd_cesu8",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"walkdir",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-macros"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"simd_cesu8",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2036,6 +2134,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2288,6 +2392,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -2303,6 +2408,18 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-osa-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2366,6 +2483,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2382,6 +2505,20 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "osakit"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
"objc2-osa-kit",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -2615,7 +2752,9 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
|
"tauri-plugin-updater",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2804,15 +2943,20 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -2824,6 +2968,20 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -2852,6 +3010,79 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-native-certs"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||||
|
dependencies = [
|
||||||
|
"openssl-probe",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"jni 0.22.4",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"rustls-platform-verifier-android",
|
||||||
|
"rustls-webpki",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"webpki-root-certs",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier-android"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -2867,6 +3098,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -2924,6 +3164,29 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "3.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.36.1"
|
version = "0.36.1"
|
||||||
@@ -3145,6 +3408,22 @@ version = "0.3.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd_cesu8"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||||
|
dependencies = [
|
||||||
|
"rustc_version",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simdutf8"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -3257,6 +3536,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.8"
|
version = "1.0.8"
|
||||||
@@ -3320,6 +3605,27 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3341,7 +3647,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
@@ -3351,7 +3657,7 @@ dependencies = [
|
|||||||
"gdkwayland-sys",
|
"gdkwayland-sys",
|
||||||
"gdkx11-sys",
|
"gdkx11-sys",
|
||||||
"gtk",
|
"gtk",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
@@ -3384,6 +3690,17 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tar"
|
||||||
|
version = "0.4.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
|
dependencies = [
|
||||||
|
"filetime",
|
||||||
|
"libc",
|
||||||
|
"xattr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.12.16"
|
version = "0.12.16"
|
||||||
@@ -3408,7 +3725,7 @@ dependencies = [
|
|||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
"image",
|
"image",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
@@ -3504,6 +3821,32 @@ dependencies = [
|
|||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"glob",
|
||||||
|
"plist",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri-utils",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-process"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
|
||||||
|
dependencies = [
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-single-instance"
|
name = "tauri-plugin-single-instance"
|
||||||
version = "2.4.4"
|
version = "2.4.4"
|
||||||
@@ -3520,6 +3863,39 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-updater"
|
||||||
|
version = "2.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"dirs",
|
||||||
|
"flate2",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"infer",
|
||||||
|
"log",
|
||||||
|
"minisign-verify",
|
||||||
|
"osakit",
|
||||||
|
"percent-encoding",
|
||||||
|
"reqwest",
|
||||||
|
"rustls",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tar",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"zip",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.3"
|
version = "2.11.3"
|
||||||
@@ -3530,7 +3906,7 @@ dependencies = [
|
|||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
@@ -3553,7 +3929,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
@@ -3742,6 +4118,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.19"
|
version = "0.7.19"
|
||||||
@@ -4055,6 +4441,12 @@ version = "1.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -4298,6 +4690,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-root-certs"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@@ -4483,6 +4884,17 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows-result 0.4.1",
|
||||||
|
"windows-strings 0.5.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -4528,6 +4940,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -4823,7 +5244,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"javascriptcore-rs",
|
"javascriptcore-rs",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -4870,6 +5291,16 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -4984,6 +5415,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -5017,6 +5454,18 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||||
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
|
"crc32fast",
|
||||||
|
"indexmap 2.14.2",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
|
|||||||
@@ -15,5 +15,7 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
|
tauri-plugin-process = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default capabilities for the main window",
|
"description": "Default capabilities for the main window",
|
||||||
"windows": ["main", "vault"],
|
"windows": ["main", "vault"],
|
||||||
"permissions": ["core:default"]
|
"permissions": ["core:default", "updater:default", "process:default"]
|
||||||
}
|
}
|
||||||
@@ -200,6 +200,8 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
show_main(app);
|
show_main(app);
|
||||||
}))
|
}))
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_server_url,
|
get_server_url,
|
||||||
set_server_url,
|
set_server_url,
|
||||||
|
|||||||
@@ -23,9 +23,18 @@
|
|||||||
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http:; img-src 'self' data:; style-src 'self'"
|
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http:; img-src 'self' data:; style-src 'self'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVERjFDMzI1MURGMTlBNDAKUldSQW12RWRKY1B4WFpQN2N0RjdCemllN1ptcDhObFJQMVdHL3pLTXMzNEN6TlJoMEZhQUhDNG4K",
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/Dvorinka/Primora/releases/latest/download/latest.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
|
"createUpdaterArtifacts": true,
|
||||||
"category": "DeveloperTool",
|
"category": "DeveloperTool",
|
||||||
"shortDescription": "Primora desktop client",
|
"shortDescription": "Primora desktop client",
|
||||||
"longDescription": "Thin desktop shell for a self-hosted Primora deployment. Renders the console served by your own server.",
|
"longDescription": "Thin desktop shell for a self-hosted Primora deployment. Renders the console served by your own server.",
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
<p id="err" class="err" hidden></p>
|
<p id="err" class="err" hidden></p>
|
||||||
<p class="hint">The desktop app renders the console served by your own server. Nothing is hosted by us.</p>
|
<p class="hint">The desktop app renders the console served by your own server. Nothing is hosted by us.</p>
|
||||||
<p class="hint"><a href="#" id="vault-link" class="vlink">Open the local Secrets Vault</a></p>
|
<p class="hint"><a href="#" id="vault-link" class="vlink">Open the local Secrets Vault</a></p>
|
||||||
|
<div id="update" hidden>
|
||||||
|
<p class="hint" id="update-text"></p>
|
||||||
|
<button type="button" id="update-install">Update & restart</button>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
@@ -44,12 +48,52 @@
|
|||||||
button.textContent = "Connect";
|
button.textContent = "Connect";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Updater — check before auto-connecting: a saved URL redirects
|
||||||
|
// immediately, so the check must resolve first or it never shows.
|
||||||
|
// Bounded at 5s; failures are silent (dev builds, offline, no
|
||||||
|
// manifest). Unsigned artifacts can never install — minisign is
|
||||||
|
// enforced by the plugin against the pubkey baked into the bundle.
|
||||||
|
const updateP = (tauri?.updater && tauri?.process)
|
||||||
|
? Promise.race([
|
||||||
|
tauri.updater.check().catch(() => null),
|
||||||
|
new Promise((r) => setTimeout(() => r(null), 5000)),
|
||||||
|
])
|
||||||
|
: Promise.resolve(null);
|
||||||
|
|
||||||
if (!tauri) {
|
if (!tauri) {
|
||||||
fail("This page is meant to run inside the Primora desktop app.");
|
fail("This page is meant to run inside the Primora desktop app.");
|
||||||
} else {
|
} else {
|
||||||
// A saved deployment URL means "skip the form" on every launch.
|
// A saved deployment URL means "skip the form" on every launch —
|
||||||
const saved = await tauri.core.invoke("get_server_url").catch(() => null);
|
// unless an update is waiting, which wins the redirect.
|
||||||
if (saved) location.replace(saved);
|
const update = await updateP;
|
||||||
|
if (update) {
|
||||||
|
const box = document.getElementById("update");
|
||||||
|
const text = document.getElementById("update-text");
|
||||||
|
const install = document.getElementById("update-install");
|
||||||
|
text.textContent = `Update available — v${update.version} (you have ${update.currentVersion}).`;
|
||||||
|
box.hidden = false;
|
||||||
|
install.addEventListener("click", async () => {
|
||||||
|
install.disabled = true;
|
||||||
|
install.textContent = "Downloading…";
|
||||||
|
try {
|
||||||
|
await update.downloadAndInstall();
|
||||||
|
await tauri.process.relaunch();
|
||||||
|
} catch (e) {
|
||||||
|
install.disabled = false;
|
||||||
|
install.textContent = "Update & restart";
|
||||||
|
text.textContent = `Update failed: ${e}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// "Not now" continues to the saved server.
|
||||||
|
const later = document.createElement("button");
|
||||||
|
later.type = "button";
|
||||||
|
later.textContent = "Not now";
|
||||||
|
later.style.marginLeft = "0.5rem";
|
||||||
|
later.addEventListener("click", () => { const saved = _savedUrl; if (saved) location.replace(saved); });
|
||||||
|
box.appendChild(later);
|
||||||
|
}
|
||||||
|
const _savedUrl = await tauri.core.invoke("get_server_url").catch(() => null);
|
||||||
|
if (_savedUrl && !update) location.replace(_savedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("vault-link").addEventListener("click", (e) => {
|
document.getElementById("vault-link").addEventListener("click", (e) => {
|
||||||
|
|||||||
@@ -27,13 +27,20 @@ RUN npm run build
|
|||||||
# Stage 2: Serve with Nginx
|
# Stage 2: Serve with Nginx
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
COPY --from=builder /app/apps/frontend/dist /usr/share/nginx/html
|
COPY --from=builder /app/apps/frontend/dist /usr/share/nginx/html
|
||||||
# Add simple nginx config for SPA routing
|
# Add simple nginx config for SPA routing. index.html must always revalidate —
|
||||||
|
# it references hashed assets that change every deploy. Hashed /assets files are
|
||||||
|
# immutable and safe to cache long-term.
|
||||||
RUN echo 'server { \
|
RUN echo 'server { \
|
||||||
listen 80; \
|
listen 80; \
|
||||||
location / { \
|
|
||||||
root /usr/share/nginx/html; \
|
root /usr/share/nginx/html; \
|
||||||
index index.html; \
|
index index.html; \
|
||||||
|
location /assets/ { \
|
||||||
|
try_files $uri =404; \
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable"; \
|
||||||
|
} \
|
||||||
|
location / { \
|
||||||
try_files $uri $uri/ /index.html; \
|
try_files $uri $uri/ /index.html; \
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate"; \
|
||||||
} \
|
} \
|
||||||
}' > /etc/nginx/conf.d/default.conf
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,6 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Primora" />
|
<meta name="apple-mobile-web-app-title" content="Primora" />
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
html { background: #0a0a0b; color-scheme: dark; }
|
html { background: #0a0a0b; color-scheme: dark; }
|
||||||
html.light { background: #fafaf9; color-scheme: light; }
|
html.light { background: #fafaf9; color-scheme: light; }
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/geist": "^5.3.0",
|
||||||
|
"@fontsource/geist-mono": "^5.3.0",
|
||||||
"@primora/api-client": "0.5.0",
|
"@primora/api-client": "0.5.0",
|
||||||
"@primora/shared-types": "0.5.0",
|
"@primora/shared-types": "0.5.0",
|
||||||
"better-auth": "^1.5.6",
|
"better-auth": "^1.5.6",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js";
|
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js";
|
||||||
|
|
||||||
import { authClient, fetchApiToken } from "./lib/auth-client";
|
import { authClient, fetchApiToken } from "./lib/auth-client";
|
||||||
import { configureApiToken } from "./lib/api";
|
import { configureApiToken, errorMessage } from "./lib/api";
|
||||||
import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode";
|
import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode";
|
||||||
import {
|
import {
|
||||||
AppShell,
|
AppShell,
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
TelemetryPage,
|
TelemetryPage,
|
||||||
IntegrationsPage,
|
IntegrationsPage,
|
||||||
VaultPage,
|
VaultPage,
|
||||||
|
FunctionsPage,
|
||||||
LoginPage,
|
LoginPage,
|
||||||
MembersPage,
|
MembersPage,
|
||||||
ProjectsPage,
|
ProjectsPage,
|
||||||
@@ -63,6 +64,7 @@ import {
|
|||||||
IconDatabases,
|
IconDatabases,
|
||||||
IconTelemetry,
|
IconTelemetry,
|
||||||
IconZap,
|
IconZap,
|
||||||
|
IconTerminal,
|
||||||
IconCollections,
|
IconCollections,
|
||||||
IconAudit,
|
IconAudit,
|
||||||
IconAuth,
|
IconAuth,
|
||||||
@@ -82,11 +84,7 @@ type ObjectPreview =
|
|||||||
| { kind: "unsupported"; message: string };
|
| { kind: "unsupported"; message: string };
|
||||||
|
|
||||||
function getErrorMessage(error: unknown, fallback: string) {
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
if (error && typeof error === "object" && "message" in error) {
|
return errorMessage(error, fallback);
|
||||||
const message = (error as { message?: unknown }).message;
|
|
||||||
if (typeof message === "string" && message.length > 0) return message;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value?: string | null) {
|
function formatDate(value?: string | null) {
|
||||||
@@ -1533,6 +1531,7 @@ export default function App() {
|
|||||||
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
|
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
|
||||||
{ id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: <IconZap class="w-4 h-4" />, keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") },
|
{ id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: <IconZap class="w-4 h-4" />, keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") },
|
||||||
{ id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: <IconKey class="w-4 h-4" />, keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") },
|
{ id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: <IconKey class="w-4 h-4" />, keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") },
|
||||||
|
{ id: "nav-functions", label: "Go to Functions", category: "Navigate", icon: <IconTerminal class="w-4 h-4" />, keywords: ["serverless", "code", "lambda", "invoke", "edge"], action: () => setActiveView("functions") },
|
||||||
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
|
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
|
||||||
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
|
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
|
||||||
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
|
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
|
||||||
@@ -1722,6 +1721,8 @@ export default function App() {
|
|||||||
|
|
||||||
<Show when={activeView() === "members"}>
|
<Show when={activeView() === "members"}>
|
||||||
<MembersPage
|
<MembersPage
|
||||||
|
projectID={activeProject()?.id}
|
||||||
|
demoMode={isDemo}
|
||||||
organizationMembers={organizationMembers()}
|
organizationMembers={organizationMembers()}
|
||||||
organizationInvitations={organizationInvitations()}
|
organizationInvitations={organizationInvitations()}
|
||||||
projectMembers={projectMembers()}
|
projectMembers={projectMembers()}
|
||||||
@@ -1782,6 +1783,14 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeView() === "functions"}>
|
||||||
|
<FunctionsPage
|
||||||
|
projectID={activeProject()?.id}
|
||||||
|
canManage={canUpdateProject()}
|
||||||
|
demoMode={isDemo}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={activeView() === "storage"}>
|
<Show when={activeView() === "storage"}>
|
||||||
<StoragePage
|
<StoragePage
|
||||||
buckets={buckets()}
|
buckets={buckets()}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
IconTelemetry,
|
IconTelemetry,
|
||||||
IconIntegrations,
|
IconIntegrations,
|
||||||
IconZap,
|
IconZap,
|
||||||
|
IconTerminal,
|
||||||
IconKey,
|
IconKey,
|
||||||
IconAudit,
|
IconAudit,
|
||||||
IconAuth,
|
IconAuth,
|
||||||
@@ -33,6 +34,7 @@ export type ViewType =
|
|||||||
| "telemetry"
|
| "telemetry"
|
||||||
| "integrations"
|
| "integrations"
|
||||||
| "automation"
|
| "automation"
|
||||||
|
| "functions"
|
||||||
| "vault"
|
| "vault"
|
||||||
| "auth"
|
| "auth"
|
||||||
| "audit"
|
| "audit"
|
||||||
@@ -51,6 +53,7 @@ const projectNav: NavEntry[] = [
|
|||||||
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
||||||
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
|
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
|
||||||
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
|
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
|
||||||
|
{ id: "functions", label: "Functions", shortLabel: "Funcs", icon: IconTerminal },
|
||||||
{ id: "vault", label: "Vault", icon: IconKey },
|
{ id: "vault", label: "Vault", icon: IconKey },
|
||||||
{ id: "storage", label: "Storage", icon: IconStorage },
|
{ id: "storage", label: "Storage", icon: IconStorage },
|
||||||
{ id: "collections", label: "Collections", icon: IconCollections },
|
{ id: "collections", label: "Collections", icon: IconCollections },
|
||||||
@@ -99,6 +102,7 @@ const viewTitle: Record<ViewType, string> = {
|
|||||||
telemetry: "Telemetry",
|
telemetry: "Telemetry",
|
||||||
integrations: "Integrations",
|
integrations: "Integrations",
|
||||||
automation: "Automation",
|
automation: "Automation",
|
||||||
|
functions: "Functions",
|
||||||
vault: "Vault",
|
vault: "Vault",
|
||||||
auth: "Authentication",
|
auth: "Authentication",
|
||||||
audit: "Audit log",
|
audit: "Audit log",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type JSX, Show, createEffect, onCleanup, splitProps } from "solid-js";
|
import { type JSX, Show, createEffect, createUniqueId, onCleanup, splitProps } from "solid-js";
|
||||||
import { Portal } from "solid-js/web";
|
import { Portal } from "solid-js/web";
|
||||||
|
|
||||||
interface ModalProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
interface ModalProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||||
@@ -6,6 +6,7 @@ interface ModalProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
error?: JSX.Element;
|
||||||
size?: "sm" | "md" | "lg" | "xl" | "full";
|
size?: "sm" | "md" | "lg" | "xl" | "full";
|
||||||
closeOnEscape?: boolean;
|
closeOnEscape?: boolean;
|
||||||
closeOnBackdrop?: boolean;
|
closeOnBackdrop?: boolean;
|
||||||
@@ -26,6 +27,7 @@ export function Modal(props: ModalProps) {
|
|||||||
"onClose",
|
"onClose",
|
||||||
"title",
|
"title",
|
||||||
"description",
|
"description",
|
||||||
|
"error",
|
||||||
"size",
|
"size",
|
||||||
"closeOnEscape",
|
"closeOnEscape",
|
||||||
"closeOnBackdrop",
|
"closeOnBackdrop",
|
||||||
@@ -38,6 +40,8 @@ export function Modal(props: ModalProps) {
|
|||||||
const closeOnEscape = () => local.closeOnEscape ?? true;
|
const closeOnEscape = () => local.closeOnEscape ?? true;
|
||||||
const closeOnBackdrop = () => local.closeOnBackdrop ?? true;
|
const closeOnBackdrop = () => local.closeOnBackdrop ?? true;
|
||||||
const showClose = () => local.showClose ?? true;
|
const showClose = () => local.showClose ?? true;
|
||||||
|
const titleId = createUniqueId();
|
||||||
|
const descriptionId = createUniqueId();
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (local.open) {
|
if (local.open) {
|
||||||
@@ -71,8 +75,8 @@ export function Modal(props: ModalProps) {
|
|||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in"
|
class="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={local.title ? "modal-title" : undefined}
|
aria-labelledby={local.title ? titleId : undefined}
|
||||||
aria-describedby={local.description ? "modal-description" : undefined}
|
aria-describedby={local.description ? descriptionId : undefined}
|
||||||
>
|
>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
@@ -91,12 +95,12 @@ export function Modal(props: ModalProps) {
|
|||||||
<div class="flex items-start justify-between p-6 border-b border-border">
|
<div class="flex items-start justify-between p-6 border-b border-border">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<Show when={local.title}>
|
<Show when={local.title}>
|
||||||
<h2 id="modal-title" class="text-xl font-semibold text-text-1">
|
<h2 id={titleId} class="text-xl font-semibold text-text-1">
|
||||||
{local.title}
|
{local.title}
|
||||||
</h2>
|
</h2>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={local.description}>
|
<Show when={local.description}>
|
||||||
<p id="modal-description" class="mt-1.5 text-sm text-text-2">
|
<p id={descriptionId} class="mt-1.5 text-sm text-text-2">
|
||||||
{local.description}
|
{local.description}
|
||||||
</p>
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -116,7 +120,14 @@ export function Modal(props: ModalProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div class="p-6">{local.children}</div>
|
<div class="p-6">
|
||||||
|
<Show when={local.error}>
|
||||||
|
<div class="message message-error mb-4" role="alert">
|
||||||
|
{local.error}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
{local.children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
hairlines, tabular numerals, no decoration.
|
hairlines, tabular numerals, no decoration.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
|
/* Vendored via @fontsource — self-hosted, no runtime Google dependency
|
||||||
|
(the nginx CSP only allows 'self'). */
|
||||||
|
@import "@fontsource/geist/400.css";
|
||||||
|
@import "@fontsource/geist/500.css";
|
||||||
|
@import "@fontsource/geist/600.css";
|
||||||
|
@import "@fontsource/geist/700.css";
|
||||||
|
@import "@fontsource/geist-mono/400.css";
|
||||||
|
@import "@fontsource/geist-mono/500.css";
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { errorMessage } from "../api";
|
||||||
|
|
||||||
|
describe("errorMessage", () => {
|
||||||
|
it("prefers the server's reason over the HTTP status text", () => {
|
||||||
|
const apiError = {
|
||||||
|
message: "Bad Request",
|
||||||
|
body: { error: { message: "webhook url must be https (http allowed only for private/self-hosted targets)" } },
|
||||||
|
};
|
||||||
|
expect(errorMessage(apiError)).toBe(
|
||||||
|
"webhook url must be https (http allowed only for private/self-hosted targets)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to error.message when no body reason exists", () => {
|
||||||
|
expect(errorMessage(new Error("network down"))).toBe("network down");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the fallback for non-error values", () => {
|
||||||
|
expect(errorMessage(null, "Nope")).toBe("Nope");
|
||||||
|
expect(errorMessage(undefined)).toBe("Request failed");
|
||||||
|
expect(errorMessage("string failure")).toBe("Request failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores empty body and message strings", () => {
|
||||||
|
expect(errorMessage({ message: "", body: { error: { message: "" } } }, "Empty")).toBe("Empty");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import { OpenAPI } from "@primora/api-client";
|
|||||||
import { fetchApiToken } from "./auth-client";
|
import { fetchApiToken } from "./auth-client";
|
||||||
|
|
||||||
OpenAPI.BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
|
OpenAPI.BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
// encodeURI leaves ?#& intact in path params — smuggles query segments.
|
||||||
|
OpenAPI.ENCODE_PATH = encodeURIComponent;
|
||||||
OpenAPI.CREDENTIALS = "include";
|
OpenAPI.CREDENTIALS = "include";
|
||||||
OpenAPI.WITH_CREDENTIALS = true;
|
OpenAPI.WITH_CREDENTIALS = true;
|
||||||
OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? "";
|
OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? "";
|
||||||
@@ -10,3 +12,20 @@ OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? "";
|
|||||||
export function configureApiToken(tokenResolver?: () => Promise<string | undefined>) {
|
export function configureApiToken(tokenResolver?: () => Promise<string | undefined>) {
|
||||||
OpenAPI.TOKEN = tokenResolver ? async () => (await tokenResolver()) ?? "" : async () => (await fetchApiToken()) ?? "";
|
OpenAPI.TOKEN = tokenResolver ? async () => (await tokenResolver()) ?? "" : async () => (await fetchApiToken()) ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a human-readable message from an API failure.
|
||||||
|
* ApiError.message is only the HTTP status text ("Bad Request") — the backend
|
||||||
|
* sends the actionable reason in body.error.message. Prefer it.
|
||||||
|
*/
|
||||||
|
export function errorMessage(error: unknown, fallback = "Request failed"): string {
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
const body = (error as { body?: { error?: { message?: unknown } } }).body;
|
||||||
|
if (typeof body?.error?.message === "string" && body.error.message.length > 0) {
|
||||||
|
return body.error.message;
|
||||||
|
}
|
||||||
|
const message = (error as { message?: unknown }).message;
|
||||||
|
if (typeof message === "string" && message.length > 0) return message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -21,25 +21,63 @@ const PAGE_SIZE = 25;
|
|||||||
|
|
||||||
const ACTION_OPTIONS = [
|
const ACTION_OPTIONS = [
|
||||||
"",
|
"",
|
||||||
|
"platform.bootstrap",
|
||||||
|
"organization.created",
|
||||||
|
"organization.updated",
|
||||||
|
"organization.deleted",
|
||||||
|
"organization.member.role_updated",
|
||||||
|
"organization.member.removed",
|
||||||
"project.created",
|
"project.created",
|
||||||
"project.updated",
|
"project.updated",
|
||||||
"project.deleted",
|
"project.deleted",
|
||||||
|
"project.member.role_updated",
|
||||||
|
"project.member.removed",
|
||||||
|
"invitation.created",
|
||||||
|
"invitation.accepted",
|
||||||
|
"invitation.revoked",
|
||||||
"bucket.created",
|
"bucket.created",
|
||||||
"bucket.updated",
|
"bucket.updated",
|
||||||
"bucket.deleted",
|
"bucket.deleted",
|
||||||
"object.uploaded",
|
"object.uploaded",
|
||||||
"object.updated",
|
"object.updated",
|
||||||
"object.deleted",
|
"object.deleted",
|
||||||
|
"object.copied",
|
||||||
|
"object.moved",
|
||||||
"api_key.created",
|
"api_key.created",
|
||||||
"api_key.revoked",
|
"api_key.revoked",
|
||||||
"member.invited",
|
|
||||||
"member.role_updated",
|
|
||||||
"member.removed",
|
|
||||||
"collection.created",
|
"collection.created",
|
||||||
|
"collection.updated",
|
||||||
"collection.deleted",
|
"collection.deleted",
|
||||||
"document.created",
|
"document.created",
|
||||||
"document.updated",
|
"document.updated",
|
||||||
"document.deleted",
|
"document.deleted",
|
||||||
|
"db_connection.created",
|
||||||
|
"db_connection.deleted",
|
||||||
|
"db_connection.transferred",
|
||||||
|
"secret.set",
|
||||||
|
"secret.revealed",
|
||||||
|
"secret.deleted",
|
||||||
|
"function.created",
|
||||||
|
"function.updated",
|
||||||
|
"function.deleted",
|
||||||
|
"function.invoked",
|
||||||
|
"job.created",
|
||||||
|
"job.updated",
|
||||||
|
"job.deleted",
|
||||||
|
"job.run",
|
||||||
|
"webhook.created",
|
||||||
|
"webhook.updated",
|
||||||
|
"webhook.deleted",
|
||||||
|
"webhook.tested",
|
||||||
|
"inbound_hook.created",
|
||||||
|
"inbound_hook.deleted",
|
||||||
|
"integration.created",
|
||||||
|
"integration.deleted",
|
||||||
|
"integration.tested",
|
||||||
|
"alert.created",
|
||||||
|
"alert.updated",
|
||||||
|
"alert.deleted",
|
||||||
|
"deploy.marker",
|
||||||
];
|
];
|
||||||
|
|
||||||
function actionVariant(action: string): "success" | "error" | "primary" | "warning" | "neutral" {
|
function actionVariant(action: string): "success" | "error" | "primary" | "warning" | "neutral" {
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js";
|
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js";
|
||||||
import {
|
import {
|
||||||
AutomationService,
|
AutomationService,
|
||||||
|
FunctionsService,
|
||||||
OpenAPI,
|
OpenAPI,
|
||||||
type ScheduledJob,
|
type ScheduledJob,
|
||||||
type ScheduledJobRun,
|
type ScheduledJobRun,
|
||||||
|
type Function as PrimoraFunction,
|
||||||
} from "@primora/api-client";
|
} from "@primora/api-client";
|
||||||
import { fetchApiToken } from "../lib/auth-client";
|
import { fetchApiToken } from "../lib/auth-client";
|
||||||
import { demoService } from "../lib/demo-mode";
|
import { demoService } from "../lib/demo-mode";
|
||||||
import { Badge } from "../components/Badge";
|
import { Badge } from "../components/Badge";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { Input, Textarea } from "../components/Input";
|
import { Input, Textarea } from "../components/Input";
|
||||||
|
import { errorMessage } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
@@ -35,7 +38,7 @@ interface RealtimeEvent {
|
|||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
const err = (e: unknown) => errorMessage(e, String(e));
|
||||||
const svc = (demo: boolean) =>
|
const svc = (demo: boolean) =>
|
||||||
(demo ? (demoService as unknown as typeof AutomationService) : AutomationService);
|
(demo ? (demoService as unknown as typeof AutomationService) : AutomationService);
|
||||||
|
|
||||||
@@ -63,7 +66,8 @@ export function AutomationPage(props: AutomationPageProps) {
|
|||||||
const [runs, setRuns] = createSignal<Record<string, ScheduledJobRun[]>>({});
|
const [runs, setRuns] = createSignal<Record<string, ScheduledJobRun[]>>({});
|
||||||
const [expandedJob, setExpandedJob] = createSignal<string>();
|
const [expandedJob, setExpandedJob] = createSignal<string>();
|
||||||
const [showCreate, setShowCreate] = createSignal(false);
|
const [showCreate, setShowCreate] = createSignal(false);
|
||||||
const [jobForm, setJobForm] = createSignal({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true });
|
const [jobForm, setJobForm] = createSignal({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true, target: "url" as "url" | "function", function_id: "" });
|
||||||
|
const [functions, setFunctions] = createSignal<PrimoraFunction[]>([]);
|
||||||
const [createdSecret, setCreatedSecret] = createSignal("");
|
const [createdSecret, setCreatedSecret] = createSignal("");
|
||||||
const [loading, setLoading] = createSignal(false);
|
const [loading, setLoading] = createSignal(false);
|
||||||
const [busy, setBusy] = createSignal<string>();
|
const [busy, setBusy] = createSignal<string>();
|
||||||
@@ -82,6 +86,10 @@ export function AutomationPage(props: AutomationPageProps) {
|
|||||||
try {
|
try {
|
||||||
const res = await svc(props.demoMode).listScheduledJobs({ projectId: props.projectID });
|
const res = await svc(props.demoMode).listScheduledJobs({ projectId: props.projectID });
|
||||||
setJobs(res.items ?? []);
|
setJobs(res.items ?? []);
|
||||||
|
if (!props.demoMode) {
|
||||||
|
const fr = await FunctionsService.listFunctions({ projectId: props.projectID });
|
||||||
|
setFunctions(fr.items ?? []);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(err(e));
|
setError(err(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -199,14 +207,15 @@ export function AutomationPage(props: AutomationPageProps) {
|
|||||||
requestBody: {
|
requestBody: {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
schedule: form.schedule.trim(),
|
schedule: form.schedule.trim(),
|
||||||
url: form.url.trim(),
|
url: form.target === "url" ? form.url.trim() : undefined,
|
||||||
|
function_id: form.target === "function" ? form.function_id : undefined,
|
||||||
secret: form.secret.trim() || undefined,
|
secret: form.secret.trim() || undefined,
|
||||||
payload,
|
payload,
|
||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (created.secret) setCreatedSecret(created.secret);
|
if (created.secret) setCreatedSecret(created.secret);
|
||||||
setJobForm({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true });
|
setJobForm({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true, target: "url", function_id: "" });
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
}, "Schedule created");
|
}, "Schedule created");
|
||||||
};
|
};
|
||||||
@@ -335,7 +344,9 @@ export function AutomationPage(props: AutomationPageProps) {
|
|||||||
<code class="text-xs">{job.schedule}</code>
|
<code class="text-xs">{job.schedule}</code>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-text-2" style="max-width:14rem;overflow:hidden;text-overflow:ellipsis">
|
<td class="text-text-2" style="max-width:14rem;overflow:hidden;text-overflow:ellipsis">
|
||||||
{job.url}
|
{job.function_id
|
||||||
|
? `ƒ ${functions().find((f) => f.id === job.function_id)?.name ?? "function"}`
|
||||||
|
: job.url}
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden md:table-cell text-text-2">{job.enabled ? fmtTime(job.next_run_at) : "—"}</td>
|
<td class="hidden md:table-cell text-text-2">{job.enabled ? fmtTime(job.next_run_at) : "—"}</td>
|
||||||
<td class="hidden md:table-cell">
|
<td class="hidden md:table-cell">
|
||||||
@@ -522,7 +533,7 @@ export function AutomationPage(props: AutomationPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New schedule" size="md">
|
<Modal error={error()} open={showCreate()} onClose={() => setShowCreate(false)} title="New schedule" size="md">
|
||||||
<form onSubmit={createJob} class="space-y-4">
|
<form onSubmit={createJob} class="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
label="Name"
|
label="Name"
|
||||||
@@ -545,6 +556,27 @@ export function AutomationPage(props: AutomationPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<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
|
<Input
|
||||||
label="Target URL"
|
label="Target URL"
|
||||||
value={jobForm().url}
|
value={jobForm().url}
|
||||||
@@ -555,6 +587,25 @@ export function AutomationPage(props: AutomationPageProps) {
|
|||||||
<p class="label-hint">
|
<p class="label-hint">
|
||||||
HTTPS for public endpoints; plain HTTP allowed for private/self-hosted targets.
|
HTTPS for public endpoints; plain HTTP allowed for private/self-hosted targets.
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
class="input"
|
||||||
|
value={jobForm().function_id}
|
||||||
|
onChange={(e) => setJobForm((c) => ({ ...c, function_id: e.currentTarget.value }))}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a function…</option>
|
||||||
|
<For each={functions()}>
|
||||||
|
{(f) => <option value={f.id}>{f.name} ({f.runtime})</option>}
|
||||||
|
</For>
|
||||||
|
</select>
|
||||||
|
<p class="label-hint">
|
||||||
|
The function receives the resolved payload as JSON. Runs appear under the function's
|
||||||
|
history.
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Payload (optional JSON)"
|
label="Payload (optional JSON)"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { demoService } from "../lib/demo-mode";
|
|||||||
import { Badge } from "../components/Badge";
|
import { Badge } from "../components/Badge";
|
||||||
import { Modal, ModalFooter } from "../components/Modal";
|
import { Modal, ModalFooter } from "../components/Modal";
|
||||||
import { Input, Select, Textarea } from "../components/Input";
|
import { Input, Select, Textarea } from "../components/Input";
|
||||||
|
import { errorMessage } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconDatabases,
|
IconDatabases,
|
||||||
@@ -57,7 +58,7 @@ const emptyForm: ConnForm = {
|
|||||||
const dbTypes = Object.values(CreateDBConnectionRequest.db_type);
|
const dbTypes = Object.values(CreateDBConnectionRequest.db_type);
|
||||||
const sqlish = (t?: string) => t !== "redis";
|
const sqlish = (t?: string) => t !== "redis";
|
||||||
|
|
||||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
const err = (e: unknown) => errorMessage(e, String(e));
|
||||||
|
|
||||||
/* the demo service mirrors the generated DatabasesService call shapes */
|
/* the demo service mirrors the generated DatabasesService call shapes */
|
||||||
const svc = (demo: boolean) =>
|
const svc = (demo: boolean) =>
|
||||||
@@ -933,6 +934,7 @@ export function DatabasesPage(props: DatabasesPageProps) {
|
|||||||
<Modal
|
<Modal
|
||||||
open={addOpen()}
|
open={addOpen()}
|
||||||
onClose={() => setAddOpen(false)}
|
onClose={() => setAddOpen(false)}
|
||||||
|
error={error()}
|
||||||
title="Add connection"
|
title="Add connection"
|
||||||
description="Credentials are stored server-side and never sent back to the browser."
|
description="Credentials are stored server-side and never sent back to the browser."
|
||||||
>
|
>
|
||||||
@@ -1024,6 +1026,7 @@ export function DatabasesPage(props: DatabasesPageProps) {
|
|||||||
<Modal
|
<Modal
|
||||||
open={!!confirmDelete()}
|
open={!!confirmDelete()}
|
||||||
onClose={() => setConfirmDelete(undefined)}
|
onClose={() => setConfirmDelete(undefined)}
|
||||||
|
error={error()}
|
||||||
title="Delete connection"
|
title="Delete connection"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 { For, Show, createEffect, createSignal } from "solid-js";
|
||||||
import {
|
import {
|
||||||
|
AlertsService,
|
||||||
|
AutomationService,
|
||||||
|
CreateInboundHookRequest,
|
||||||
CreateIntegrationRequest,
|
CreateIntegrationRequest,
|
||||||
|
FunctionsService,
|
||||||
|
InboundService,
|
||||||
IntegrationsService,
|
IntegrationsService,
|
||||||
|
UpsertAlertRuleRequest,
|
||||||
WebhooksService,
|
WebhooksService,
|
||||||
|
type AlertRule,
|
||||||
|
type Function as PrimoraFunction,
|
||||||
|
type InboundHook,
|
||||||
|
type ScheduledJob,
|
||||||
type Integration,
|
type Integration,
|
||||||
type IntegrationAnalytics,
|
type IntegrationAnalytics,
|
||||||
type Webhook,
|
type Webhook,
|
||||||
@@ -12,6 +22,7 @@ import { demoService } from "../lib/demo-mode";
|
|||||||
import { Badge } from "../components/Badge";
|
import { Badge } from "../components/Badge";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { Input, Select } from "../components/Input";
|
import { Input, Select } from "../components/Input";
|
||||||
|
import { errorMessage } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
@@ -30,7 +41,7 @@ interface IntegrationsPageProps {
|
|||||||
|
|
||||||
type Tab = "integrations" | "webhooks";
|
type Tab = "integrations" | "webhooks";
|
||||||
|
|
||||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
const err = (e: unknown) => errorMessage(e, String(e));
|
||||||
const intSvc = (demo: boolean) =>
|
const intSvc = (demo: boolean) =>
|
||||||
(demo ? (demoService as unknown as typeof IntegrationsService) : IntegrationsService);
|
(demo ? (demoService as unknown as typeof IntegrationsService) : IntegrationsService);
|
||||||
const hookSvc = (demo: boolean) =>
|
const hookSvc = (demo: boolean) =>
|
||||||
@@ -48,7 +59,7 @@ const statusVariant = (s?: string) =>
|
|||||||
? "warning"
|
? "warning"
|
||||||
: "neutral";
|
: "neutral";
|
||||||
|
|
||||||
const EVENT_TYPES = ["issue.created", "deploy.marker", "webhook.test"] as const;
|
const EVENT_TYPES = ["issue.created", "deploy.marker", "webhook.test", "alert.fired", "alert.resolved"] as const;
|
||||||
|
|
||||||
export function IntegrationsPage(props: IntegrationsPageProps) {
|
export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||||
const [tab, setTab] = createSignal<Tab>("integrations");
|
const [tab, setTab] = createSignal<Tab>("integrations");
|
||||||
@@ -59,8 +70,23 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
const [analyticsSite, setAnalyticsSite] = createSignal<Record<string, string>>({});
|
const [analyticsSite, setAnalyticsSite] = createSignal<Record<string, string>>({});
|
||||||
const [expandedIntegration, setExpandedIntegration] = createSignal<string>();
|
const [expandedIntegration, setExpandedIntegration] = createSignal<string>();
|
||||||
const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
|
const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
|
||||||
|
const [alertRules, setAlertRules] = createSignal<AlertRule[]>([]);
|
||||||
|
const [inboundHooks, setInboundHooks] = createSignal<InboundHook[]>([]);
|
||||||
|
const [scheduledJobs, setScheduledJobs] = createSignal<ScheduledJob[]>([]);
|
||||||
const [showCreateIntegration, setShowCreateIntegration] = createSignal(false);
|
const [showCreateIntegration, setShowCreateIntegration] = createSignal(false);
|
||||||
const [showCreateWebhook, setShowCreateWebhook] = createSignal(false);
|
const [showCreateWebhook, setShowCreateWebhook] = createSignal(false);
|
||||||
|
const [showCreateAlert, setShowCreateAlert] = createSignal(false);
|
||||||
|
const [showCreateInbound, setShowCreateInbound] = createSignal(false);
|
||||||
|
const [inboundForm, setInboundForm] = createSignal({ name: "", mode: CreateInboundHookRequest.mode.EVENT as CreateInboundHookRequest.mode, job_id: "", function_id: "", secret: "" });
|
||||||
|
const [functions, setFunctions] = createSignal<PrimoraFunction[]>([]);
|
||||||
|
const [copiedUrl, setCopiedUrl] = createSignal("");
|
||||||
|
const [alertForm, setAlertForm] = createSignal({
|
||||||
|
name: "",
|
||||||
|
kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE as UpsertAlertRuleRequest.kind,
|
||||||
|
component: "",
|
||||||
|
threshold: "5",
|
||||||
|
window: "15",
|
||||||
|
});
|
||||||
const [integrationForm, setIntegrationForm] = createSignal({ name: "", base_url: "", api_key: "", site_id: "" });
|
const [integrationForm, setIntegrationForm] = createSignal({ name: "", base_url: "", api_key: "", site_id: "" });
|
||||||
const [webhookForm, setWebhookForm] = createSignal({ url: "", secret: "", events: [] as string[], enabled: true });
|
const [webhookForm, setWebhookForm] = createSignal({ url: "", secret: "", events: [] as string[], enabled: true });
|
||||||
const [markerForm, setMarkerForm] = createSignal({ version: "", ref: "", environment: "", note: "" });
|
const [markerForm, setMarkerForm] = createSignal({ version: "", ref: "", environment: "", note: "" });
|
||||||
@@ -75,12 +101,20 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const [int, hooks] = await Promise.all([
|
const [int, hooks, rules, inbound, jobs, fns] = await Promise.all([
|
||||||
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
|
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
|
||||||
hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }),
|
hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }),
|
||||||
|
props.demoMode ? Promise.resolve({ items: [] }) : AlertsService.listAlertRules({ projectId: props.projectID }),
|
||||||
|
props.demoMode ? Promise.resolve({ items: [] }) : InboundService.listInboundHooks({ projectId: props.projectID }),
|
||||||
|
props.demoMode ? Promise.resolve({ items: [] }) : AutomationService.listScheduledJobs({ projectId: props.projectID }),
|
||||||
|
props.demoMode ? Promise.resolve({ items: [] }) : FunctionsService.listFunctions({ projectId: props.projectID }),
|
||||||
]);
|
]);
|
||||||
setIntegrations(int.items ?? []);
|
setIntegrations(int.items ?? []);
|
||||||
setWebhooks(hooks.items ?? []);
|
setWebhooks(hooks.items ?? []);
|
||||||
|
setAlertRules(rules.items ?? []);
|
||||||
|
setInboundHooks(inbound.items ?? []);
|
||||||
|
setScheduledJobs(jobs.items ?? []);
|
||||||
|
setFunctions(fns.items ?? []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(err(e));
|
setError(err(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -205,6 +239,64 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
"Webhook deleted",
|
"Webhook deleted",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const createAlertRule = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = alertForm();
|
||||||
|
const threshold = parseInt(form.threshold, 10) || 0;
|
||||||
|
const config =
|
||||||
|
form.kind === UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE
|
||||||
|
? { component: form.component.trim() || undefined, threshold_minutes: threshold }
|
||||||
|
: { component: form.component.trim() || undefined, threshold_count: threshold, window_minutes: parseInt(form.window, 10) || 15 };
|
||||||
|
void run("create-alert", async () => {
|
||||||
|
await AlertsService.createAlertRule({
|
||||||
|
projectId: props.projectID!,
|
||||||
|
requestBody: { name: form.name.trim(), kind: form.kind, config },
|
||||||
|
});
|
||||||
|
setShowCreateAlert(false);
|
||||||
|
setAlertForm({ name: "", kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE, component: "", threshold: "5", window: "15" });
|
||||||
|
}, "Alert rule created");
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAlertRule = (id: string) =>
|
||||||
|
run(
|
||||||
|
`del-alert-${id}`,
|
||||||
|
() => AlertsService.deleteAlertRule({ projectId: props.projectID!, ruleId: id }),
|
||||||
|
"Alert rule deleted",
|
||||||
|
);
|
||||||
|
|
||||||
|
const createInboundHook = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = inboundForm();
|
||||||
|
void run("create-inbound", async () => {
|
||||||
|
await InboundService.createInboundHook({
|
||||||
|
projectId: props.projectID!,
|
||||||
|
requestBody: {
|
||||||
|
name: form.name.trim(),
|
||||||
|
mode: form.mode,
|
||||||
|
job_id: form.mode === CreateInboundHookRequest.mode.JOB ? form.job_id : undefined,
|
||||||
|
function_id: form.mode === CreateInboundHookRequest.mode.FUNCTION ? form.function_id : undefined,
|
||||||
|
secret: form.secret.trim() || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setShowCreateInbound(false);
|
||||||
|
setInboundForm({ name: "", mode: CreateInboundHookRequest.mode.EVENT, job_id: "", function_id: "", secret: "" });
|
||||||
|
}, "Inbound hook created");
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteInboundHook = (id: string) =>
|
||||||
|
run(
|
||||||
|
`del-inbound-${id}`,
|
||||||
|
() => InboundService.deleteInboundHook({ projectId: props.projectID!, hookId: id }),
|
||||||
|
"Inbound hook deleted",
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyHookUrl = (hook: InboundHook) => {
|
||||||
|
void navigator.clipboard?.writeText(hook.url).then(() => {
|
||||||
|
setCopiedUrl(hook.id);
|
||||||
|
setTimeout(() => setCopiedUrl(""), 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const testWebhook = (id: string) =>
|
const testWebhook = (id: string) =>
|
||||||
run(
|
run(
|
||||||
`test-hook-${id}`,
|
`test-hook-${id}`,
|
||||||
@@ -506,6 +598,158 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-flush mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card-header-title">Alert rules</div>
|
||||||
|
<div class="card-header-description">
|
||||||
|
Telemetry conditions that fire <code>alert.fired</code> / <code>alert.resolved</code> events —
|
||||||
|
subscribe a webhook above to receive them.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={props.canManage}>
|
||||||
|
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateAlert(true)}>
|
||||||
|
<IconPlus class="w-4 h-4" />
|
||||||
|
Add rule
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show
|
||||||
|
when={alertRules().length > 0}
|
||||||
|
fallback={
|
||||||
|
<p class="text-text-2 text-sm p-4">
|
||||||
|
No alert rules yet. Watch for heartbeat silence or error spikes across your components.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Condition</th>
|
||||||
|
<th>Scope</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th style="text-align:right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={alertRules()}>
|
||||||
|
{(rule) => (
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">{rule.name}</td>
|
||||||
|
<td class="text-text-2">
|
||||||
|
{rule.kind === "heartbeat_silence"
|
||||||
|
? `no heartbeat for ${rule.config.threshold_minutes ?? "?"}m`
|
||||||
|
: `≥${rule.config.threshold_count ?? "?"} errors in ${rule.config.window_minutes ?? 15}m`}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Show when={rule.config.component} fallback={<span class="text-text-2">all components</span>}>
|
||||||
|
<Badge variant="neutral">{rule.config.component}</Badge>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Show
|
||||||
|
when={rule.firing.length > 0}
|
||||||
|
fallback={<Badge variant={rule.enabled ? "success" : "neutral"}>{rule.enabled ? "watching" : "disabled"}</Badge>}
|
||||||
|
>
|
||||||
|
<Badge variant="error">firing: {rule.firing.join(", ")}</Badge>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;white-space:nowrap">
|
||||||
|
<Show when={props.canManage}>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => void deleteAlertRule(rule.id)}
|
||||||
|
disabled={busy() === `del-alert-${rule.id}`}
|
||||||
|
>
|
||||||
|
<IconTrash class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-flush mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card-header-title">Inbound hooks</div>
|
||||||
|
<div class="card-header-description">
|
||||||
|
Public ingest endpoints — POST from external systems to republish as <code>inbound.received</code>
|
||||||
|
events or trigger a scheduled job.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={props.canManage}>
|
||||||
|
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateInbound(true)}>
|
||||||
|
<IconPlus class="w-4 h-4" />
|
||||||
|
Add hook
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show
|
||||||
|
when={inboundHooks().length > 0}
|
||||||
|
fallback={<p class="text-text-2 text-sm p-4">No inbound hooks yet.</p>}
|
||||||
|
>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
<th>Security</th>
|
||||||
|
<th>Last received</th>
|
||||||
|
<th style="text-align:right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={inboundHooks()}>
|
||||||
|
{(hook) => (
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">{hook.name}</td>
|
||||||
|
<td>
|
||||||
|
<Badge variant="neutral">
|
||||||
|
{hook.mode === "job" ? "job trigger" : hook.mode === "function" ? `ƒ ${functions().find((f) => f.id === hook.function_id)?.name ?? "function"}` : "event"}
|
||||||
|
</Badge>
|
||||||
|
<Show when={!hook.enabled}>
|
||||||
|
{" "}<Badge variant="warning">disabled</Badge>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Show when={hook.has_secret} fallback={<span class="text-text-2">token only</span>}>
|
||||||
|
<Badge variant="success">signed</Badge>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td class="text-text-2">{fmtTime(hook.last_received_at)}</td>
|
||||||
|
<td style="text-align:right;white-space:nowrap">
|
||||||
|
<button class="btn btn-ghost btn-sm" onClick={() => copyHookUrl(hook)} title={hook.url}>
|
||||||
|
<IconCopy class="w-4 h-4" />
|
||||||
|
{copiedUrl() === hook.id ? "Copied" : "URL"}
|
||||||
|
</button>
|
||||||
|
<Show when={props.canManage}>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => void deleteInboundHook(hook.id)}
|
||||||
|
disabled={busy() === `del-inbound-${hook.id}`}
|
||||||
|
>
|
||||||
|
<IconTrash class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Show when={props.canManage}>
|
<Show when={props.canManage}>
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -555,7 +799,7 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Modal open={showCreateIntegration()} onClose={() => setShowCreateIntegration(false)} title="Add connector" size="md">
|
<Modal error={error()} open={showCreateIntegration()} onClose={() => setShowCreateIntegration(false)} title="Add connector" size="md">
|
||||||
<form onSubmit={createIntegration} class="space-y-4">
|
<form onSubmit={createIntegration} class="space-y-4">
|
||||||
<Select label="Type" value="rybbit" disabled options={[{ value: "rybbit", label: "Rybbit — web analytics" }]} />
|
<Select label="Type" value="rybbit" disabled options={[{ value: "rybbit", label: "Rybbit — web analytics" }]} />
|
||||||
<Input
|
<Input
|
||||||
@@ -596,7 +840,7 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal open={showCreateWebhook()} onClose={() => setShowCreateWebhook(false)} title="Add webhook" size="md">
|
<Modal error={error()} open={showCreateWebhook()} onClose={() => setShowCreateWebhook(false)} title="Add webhook" size="md">
|
||||||
<form onSubmit={createWebhook} class="space-y-4">
|
<form onSubmit={createWebhook} class="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
label="Endpoint URL"
|
label="Endpoint URL"
|
||||||
@@ -639,6 +883,119 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal error={error()} open={showCreateAlert()} onClose={() => setShowCreateAlert(false)} title="Add alert rule" size="md">
|
||||||
|
<form onSubmit={createAlertRule} class="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
placeholder="api_down"
|
||||||
|
required
|
||||||
|
value={alertForm().name}
|
||||||
|
onInput={(e) => setAlertForm((c) => ({ ...c, name: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Condition"
|
||||||
|
value={alertForm().kind}
|
||||||
|
onChange={(e) => setAlertForm((c) => ({ ...c, kind: e.currentTarget.value as UpsertAlertRuleRequest.kind }))}
|
||||||
|
options={[
|
||||||
|
{ value: "heartbeat_silence", label: "Heartbeat silence — component stops reporting" },
|
||||||
|
{ value: "error_spike", label: "Error spike — too many errors in a window" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Component"
|
||||||
|
placeholder="empty = all components"
|
||||||
|
value={alertForm().component}
|
||||||
|
onInput={(e) => setAlertForm((c) => ({ ...c, component: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label={alertForm().kind === UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE ? "Silent for (minutes)" : "Errors at least"}
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
value={alertForm().threshold}
|
||||||
|
onInput={(e) => setAlertForm((c) => ({ ...c, threshold: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Show when={alertForm().kind === UpsertAlertRuleRequest.kind.ERROR_SPIKE}>
|
||||||
|
<Input
|
||||||
|
label="Window (minutes)"
|
||||||
|
type="number"
|
||||||
|
value={alertForm().window}
|
||||||
|
onInput={(e) => setAlertForm((c) => ({ ...c, window: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateAlert(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={busy() === "create-alert"}>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal error={error()} open={showCreateInbound()} onClose={() => setShowCreateInbound(false)} title="Add inbound hook" size="md">
|
||||||
|
<form onSubmit={createInboundHook} class="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
placeholder="github_ci"
|
||||||
|
required
|
||||||
|
value={inboundForm().name}
|
||||||
|
onInput={(e) => setInboundForm((c) => ({ ...c, name: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="On receive"
|
||||||
|
value={inboundForm().mode}
|
||||||
|
onChange={(e) => setInboundForm((c) => ({ ...c, mode: e.currentTarget.value as CreateInboundHookRequest.mode }))}
|
||||||
|
options={[
|
||||||
|
{ value: "event", label: "Publish inbound.received event" },
|
||||||
|
{ value: "job", label: "Trigger a scheduled job" },
|
||||||
|
{ value: "function", label: "Invoke a function" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Show when={inboundForm().mode === CreateInboundHookRequest.mode.FUNCTION}>
|
||||||
|
<Select
|
||||||
|
label="Function"
|
||||||
|
value={inboundForm().function_id}
|
||||||
|
onChange={(e) => setInboundForm((c) => ({ ...c, function_id: e.currentTarget.value }))}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "Select a function…" },
|
||||||
|
...functions().map((f) => ({ value: f.id, label: f.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p class="text-text-2 text-xs">The received body becomes the function's stdin payload.</p>
|
||||||
|
</Show>
|
||||||
|
<Show when={inboundForm().mode === CreateInboundHookRequest.mode.JOB}>
|
||||||
|
<Select
|
||||||
|
label="Job"
|
||||||
|
value={inboundForm().job_id}
|
||||||
|
onChange={(e) => setInboundForm((c) => ({ ...c, job_id: e.currentTarget.value }))}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "Select a job…" },
|
||||||
|
...scheduledJobs().map((j) => ({ value: j.id, label: j.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p class="text-text-2 text-xs">The received body becomes the job's payload for that run.</p>
|
||||||
|
</Show>
|
||||||
|
<Input
|
||||||
|
label="Secret"
|
||||||
|
type="password"
|
||||||
|
placeholder="Optional — callers must sign X-Primora-Signature"
|
||||||
|
value={inboundForm().secret}
|
||||||
|
onInput={(e) => setInboundForm((c) => ({ ...c, secret: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateInbound(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={busy() === "create-inbound"}>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { For, Show, createMemo, createSignal } from "solid-js";
|
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||||
import type {
|
import {
|
||||||
OrganizationInvitation,
|
EmailService,
|
||||||
OrganizationMember,
|
type EmailLogEntry,
|
||||||
ProjectMember,
|
type OrganizationInvitation,
|
||||||
|
type OrganizationMember,
|
||||||
|
type ProjectMember,
|
||||||
} from "@primora/api-client";
|
} from "@primora/api-client";
|
||||||
import { Badge } from "../components/Badge";
|
import { Badge } from "../components/Badge";
|
||||||
import { Modal, ModalFooter } from "../components/Modal";
|
import { Modal, ModalFooter } from "../components/Modal";
|
||||||
@@ -22,6 +24,8 @@ interface InvitationInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MembersPageProps {
|
interface MembersPageProps {
|
||||||
|
projectID?: string;
|
||||||
|
demoMode?: boolean;
|
||||||
organizationMembers?: OrganizationMember[];
|
organizationMembers?: OrganizationMember[];
|
||||||
organizationInvitations?: OrganizationInvitation[];
|
organizationInvitations?: OrganizationInvitation[];
|
||||||
projectMembers?: ProjectMember[];
|
projectMembers?: ProjectMember[];
|
||||||
@@ -80,6 +84,15 @@ export function MembersPage(props: MembersPageProps) {
|
|||||||
const [tab, setTab] = createSignal<"org" | "project">("org");
|
const [tab, setTab] = createSignal<"org" | "project">("org");
|
||||||
const [inviteOpen, setInviteOpen] = createSignal(false);
|
const [inviteOpen, setInviteOpen] = createSignal(false);
|
||||||
const [query, setQuery] = createSignal("");
|
const [query, setQuery] = createSignal("");
|
||||||
|
const [emails, setEmails] = createSignal<EmailLogEntry[]>([]);
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const pid = props.projectID;
|
||||||
|
if (!pid || props.demoMode) return;
|
||||||
|
void EmailService.listEmailLog({ projectId: pid, limit: 20 })
|
||||||
|
.then((res) => setEmails(res.items ?? []))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
const filteredOrgMembers = createMemo(() => {
|
const filteredOrgMembers = createMemo(() => {
|
||||||
const q = query().toLowerCase();
|
const q = query().toLowerCase();
|
||||||
@@ -368,6 +381,49 @@ export function MembersPage(props: MembersPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={emails().length > 0}>
|
||||||
|
<div class="card card-flush">
|
||||||
|
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
|
||||||
|
<span class="card-header-title">Email log</span>
|
||||||
|
<span class="card-header-description">
|
||||||
|
Transactional email sent for this project — every send is recorded, including failures.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>To</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Template</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Sent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={emails()}>
|
||||||
|
{(mail) => (
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium text-text-1">{mail.to_email}</td>
|
||||||
|
<td class="text-text-2" style="max-width:18rem;overflow:hidden;text-overflow:ellipsis" title={mail.error || mail.subject}>
|
||||||
|
{mail.subject}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Badge variant="neutral">{mail.template}</Badge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Badge variant={mail.status === "sent" ? "success" : "error"}>{mail.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="text-text-3">{formatDate(mail.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* Invite modal */}
|
{/* Invite modal */}
|
||||||
<Modal
|
<Modal
|
||||||
open={inviteOpen()}
|
open={inviteOpen()}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { demoService } from "../lib/demo-mode";
|
|||||||
import { Badge } from "../components/Badge";
|
import { Badge } from "../components/Badge";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { Select } from "../components/Input";
|
import { Select } from "../components/Input";
|
||||||
|
import { errorMessage } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
@@ -25,7 +26,7 @@ interface TelemetryPageProps {
|
|||||||
|
|
||||||
type Tab = "health" | "issues" | "events" | "metrics" | "integrate";
|
type Tab = "health" | "issues" | "events" | "metrics" | "integrate";
|
||||||
|
|
||||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
const err = (e: unknown) => errorMessage(e, String(e));
|
||||||
const svc = (demo: boolean) =>
|
const svc = (demo: boolean) =>
|
||||||
(demo ? (demoService as unknown as typeof TelemetryService) : TelemetryService);
|
(demo ? (demoService as unknown as typeof TelemetryService) : TelemetryService);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { Input, Select, Textarea } from "../components/Input";
|
import { Input, Select, Textarea } from "../components/Input";
|
||||||
import { IconPlus, IconTrash, IconCopy, IconRefresh, IconKey } from "../components/Icons";
|
import { IconPlus, IconTrash, IconCopy, IconRefresh, IconKey } from "../components/Icons";
|
||||||
|
import { errorMessage } from "../lib/api";
|
||||||
|
|
||||||
interface VaultPageProps {
|
interface VaultPageProps {
|
||||||
projectID?: string;
|
projectID?: string;
|
||||||
@@ -13,7 +14,7 @@ interface VaultPageProps {
|
|||||||
demoMode: boolean;
|
demoMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
const err = (e: unknown) => errorMessage(e, String(e));
|
||||||
const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—");
|
const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—");
|
||||||
|
|
||||||
const DEMO_SECRETS: ProjectSecret[] = [
|
const DEMO_SECRETS: ProjectSecret[] = [
|
||||||
@@ -376,7 +377,7 @@ export function VaultPage(props: VaultPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New secret" size="md">
|
<Modal error={error()} open={showCreate()} onClose={() => setShowCreate(false)} title="New secret" size="md">
|
||||||
<form onSubmit={createSecret} class="space-y-4">
|
<form onSubmit={createSecret} class="space-y-4">
|
||||||
<Input label="Name" placeholder="STRIPE_SECRET" value={form().name} onInput={(e) => setForm({ ...form(), name: e.currentTarget.value })} required />
|
<Input label="Name" placeholder="STRIPE_SECRET" value={form().name} onInput={(e) => setForm({ ...form(), name: e.currentTarget.value })} required />
|
||||||
<Input label="Value" type="password" placeholder="sk_live_…" value={form().value} onInput={(e) => setForm({ ...form(), value: e.currentTarget.value })} required />
|
<Input label="Value" type="password" placeholder="sk_live_…" value={form().value} onInput={(e) => setForm({ ...form(), value: e.currentTarget.value })} required />
|
||||||
@@ -389,7 +390,7 @@ export function VaultPage(props: VaultPageProps) {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal open={showImport()} onClose={() => setShowImport(false)} title="Import .env" size="md">
|
<Modal error={error()} open={showImport()} onClose={() => setShowImport(false)} title="Import .env" size="md">
|
||||||
<form onSubmit={importEnv} class="space-y-4">
|
<form onSubmit={importEnv} class="space-y-4">
|
||||||
<Textarea label="Paste .env contents" rows={8} placeholder={"KEY=value\nOTHER=…"} value={importText()} onInput={(e) => setImportText(e.currentTarget.value)} class="font-mono text-sm" />
|
<Textarea label="Paste .env contents" rows={8} placeholder={"KEY=value\nOTHER=…"} value={importText()} onInput={(e) => setImportText(e.currentTarget.value)} class="font-mono text-sm" />
|
||||||
<p class="text-xs text-text-2">Existing names are overwritten. Keys that aren't env-var-safe are skipped.</p>
|
<p class="text-xs text-text-2">Existing names are overwritten. Keys that aren't env-var-safe are skipped.</p>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export { DatabasesPage } from "./DatabasesPage";
|
|||||||
export { TelemetryPage } from "./TelemetryPage";
|
export { TelemetryPage } from "./TelemetryPage";
|
||||||
export { IntegrationsPage } from "./IntegrationsPage";
|
export { IntegrationsPage } from "./IntegrationsPage";
|
||||||
export { VaultPage } from "./VaultPage";
|
export { VaultPage } from "./VaultPage";
|
||||||
|
export { FunctionsPage } from "./FunctionsPage";
|
||||||
export { AutomationPage } from "./AutomationPage";
|
export { AutomationPage } from "./AutomationPage";
|
||||||
export { SettingsPage } from "./SettingsPage";
|
export { SettingsPage } from "./SettingsPage";
|
||||||
export { AuditPage } from "./AuditPage";
|
export { AuditPage } from "./AuditPage";
|
||||||
|
|||||||
@@ -38,15 +38,6 @@ export default defineConfig({
|
|||||||
urlPattern: /\/(api|auth|mailpit)(\/|$)/,
|
urlPattern: /\/(api|auth|mailpit)(\/|$)/,
|
||||||
handler: "NetworkOnly",
|
handler: "NetworkOnly",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// Geist font files + CSS from Google Fonts — long-lived static assets.
|
|
||||||
urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\//,
|
|
||||||
handler: "CacheFirst",
|
|
||||||
options: {
|
|
||||||
cacheName: "primora-fonts",
|
|
||||||
expiration: { maxEntries: 20, maxAgeSeconds: 60 * 60 * 24 * 365 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
+6
-2
@@ -27,8 +27,12 @@ Secrets are agent-safe by design — values are never returned by any tool:
|
|||||||
- `primora_vault_status` / `primora_vault_list` — local-vault state and
|
- `primora_vault_status` / `primora_vault_list` — local-vault state and
|
||||||
metadata (names/urls/notes only); requires `primora vault unlock` on the host.
|
metadata (names/urls/notes only); requires `primora vault unlock` on the host.
|
||||||
- `primora_vault_exec` — runs `primora` commands with a vault secret injected
|
- `primora_vault_exec` — runs `primora` commands with a vault secret injected
|
||||||
into the child's env; `vault`/`secrets`/`inject`/`agent`/`login`/`use` and
|
into the child's env. A strict allow-list (read-mostly commands like
|
||||||
`keys:create` are denied so no response can carry a credential.
|
`whoami`, `context`, `orgs:list`, `buckets:list`, `jobs:list`,
|
||||||
|
`audit:list`, `events:send`) — nothing that reads, writes, or moves a
|
||||||
|
credential can run; file/path flags are rejected and the injected env
|
||||||
|
name must be a safe identifier (loader hooks like `NODE_OPTIONS` are
|
||||||
|
blocked).
|
||||||
- `primora_secrets_list` / `primora_secrets_set` — project-vault metadata and
|
- `primora_secrets_list` / `primora_secrets_set` — project-vault metadata and
|
||||||
write-only storage. No reveal or delete: agents consume secrets through
|
write-only storage. No reveal or delete: agents consume secrets through
|
||||||
`secret://NAME` references in job payloads, resolved server-side at delivery.
|
`secret://NAME` references in job payloads, resolved server-side at delivery.
|
||||||
@@ -49,6 +49,8 @@ async function mintSessionToken(cfg: McpConfig): Promise<string> {
|
|||||||
|
|
||||||
export function configureClient(cfg: McpConfig): void {
|
export function configureClient(cfg: McpConfig): void {
|
||||||
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
|
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
|
||||||
|
// encodeURI leaves ?#& intact in path params — smuggles query segments.
|
||||||
|
OpenAPI.ENCODE_PATH = encodeURIComponent;
|
||||||
if (cfg.auth?.type === "apiKey") {
|
if (cfg.auth?.type === "apiKey") {
|
||||||
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
|
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
|
||||||
return;
|
return;
|
||||||
|
|||||||
+164
-56
@@ -22,7 +22,16 @@ import {
|
|||||||
import { configureClient, loadConfig, type McpConfig } from "./config.js";
|
import { configureClient, loadConfig, type McpConfig } from "./config.js";
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
configureClient(cfg);
|
try {
|
||||||
|
configureClient(cfg);
|
||||||
|
} catch (e) {
|
||||||
|
// Missing credentials must not kill the server — initialize/tools/list
|
||||||
|
// still work, and every call surfaces the actionable error through the
|
||||||
|
// normal tool error path instead of an uncaught startup exception.
|
||||||
|
OpenAPI.TOKEN = async () => {
|
||||||
|
throw e;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const TEXT_TYPES = /^(text\/|application\/(json|xml|yaml|javascript|x-ndjson))/;
|
const TEXT_TYPES = /^(text\/|application\/(json|xml|yaml|javascript|x-ndjson))/;
|
||||||
|
|
||||||
@@ -41,14 +50,62 @@ function fail(error: unknown) {
|
|||||||
return { content: [{ type: "text" as const, text: `error: ${message}` }], isError: true };
|
return { content: [{ type: "text" as const, text: `error: ${message}` }], isError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectId(override?: string): string {
|
async function projectId(override?: string): Promise<string> {
|
||||||
const id = override ?? cfg.projectId;
|
// API keys are hard-scoped to one project — a stored `primora use`
|
||||||
|
// selection for a different project can never authorize, so /context
|
||||||
|
// wins. Session actors keep the stored choice (users switch projects).
|
||||||
|
const id =
|
||||||
|
override ??
|
||||||
|
(cfg.auth?.type === "apiKey"
|
||||||
|
? ((await contextProjectId()) ?? cfg.projectId)
|
||||||
|
: (cfg.projectId ?? (await contextProjectId())));
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error("no project selected — pass projectId, run `primora use`, or set PRIMORA_PROJECT");
|
throw new Error("no project selected — pass projectId, run `primora use`, or set PRIMORA_PROJECT");
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function orgId(override?: string): Promise<string> {
|
||||||
|
const id =
|
||||||
|
override ??
|
||||||
|
(cfg.auth?.type === "apiKey"
|
||||||
|
? ((await contextOrgId()) ?? cfg.organizationId)
|
||||||
|
: (cfg.organizationId ?? (await contextOrgId())));
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("no organization selected — pass organizationId or run `primora use`");
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API keys are project-scoped — /context resolves the project without /me.
|
||||||
|
async function actorContext(): Promise<ActorContext | undefined> {
|
||||||
|
if (ctxCache) return ctxCache;
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/context");
|
||||||
|
ctxCache = (await res.json()) as ActorContext;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return ctxCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contextProjectId(): Promise<string | undefined> {
|
||||||
|
return (await actorContext())?.project?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contextOrgId(): Promise<string | undefined> {
|
||||||
|
return (await actorContext())?.organization?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActorContext {
|
||||||
|
actor: string;
|
||||||
|
scopes?: string[];
|
||||||
|
key_prefix?: string;
|
||||||
|
organization?: { id: string; slug: string; name: string; role?: string };
|
||||||
|
project?: { id: string; slug: string; name: string };
|
||||||
|
}
|
||||||
|
let ctxCache: ActorContext | undefined;
|
||||||
|
|
||||||
async function bucketIdFor(project: string, ref: string): Promise<string> {
|
async function bucketIdFor(project: string, ref: string): Promise<string> {
|
||||||
const { items } = await StorageService.listBuckets({ projectId: project });
|
const { items } = await StorageService.listBuckets({ projectId: project });
|
||||||
const match = items.find((b) => b.id === ref || b.slug === ref || b.name === ref);
|
const match = items.find((b) => b.id === ref || b.slug === ref || b.name === ref);
|
||||||
@@ -83,6 +140,11 @@ server.registerTool(
|
|||||||
{ description: "Current identity, organizations and projects visible to the credentials" },
|
{ description: "Current identity, organizations and projects visible to the credentials" },
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
|
// API keys can't call /me — /context is the actor-aware self-discovery.
|
||||||
|
if (cfg.auth?.type === "apiKey") {
|
||||||
|
const res = await apiFetch("/context");
|
||||||
|
return ok(await res.json());
|
||||||
|
}
|
||||||
return ok(await PlatformService.getMe());
|
return ok(await PlatformService.getMe());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
@@ -110,9 +172,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ organizationId }) => {
|
async ({ organizationId }) => {
|
||||||
try {
|
try {
|
||||||
const org = organizationId ?? cfg.organizationId;
|
return ok(await ProjectsService.listProjects({ organizationId: await orgId(organizationId) }));
|
||||||
if (!org) throw new Error("no organization selected — pass organizationId or run `primora use`");
|
|
||||||
return ok(await ProjectsService.listProjects({ organizationId: org }));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -132,11 +192,9 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ name, slug, description, organizationId }) => {
|
async ({ name, slug, description, organizationId }) => {
|
||||||
try {
|
try {
|
||||||
const org = organizationId ?? cfg.organizationId;
|
|
||||||
if (!org) throw new Error("no organization selected — pass organizationId or run `primora use`");
|
|
||||||
return ok(
|
return ok(
|
||||||
await ProjectsService.createProject({
|
await ProjectsService.createProject({
|
||||||
organizationId: org,
|
organizationId: await orgId(organizationId),
|
||||||
requestBody: { name, slug: slug ?? slugify(name), description },
|
requestBody: { name, slug: slug ?? slugify(name), description },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -154,7 +212,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ projectId: p }) => {
|
async ({ projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
return ok(await StorageService.listBuckets({ projectId: projectId(p) }));
|
return ok(await StorageService.listBuckets({ projectId: await projectId(p) }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -176,7 +234,7 @@ server.registerTool(
|
|||||||
try {
|
try {
|
||||||
return ok(
|
return ok(
|
||||||
await StorageService.createBucket({
|
await StorageService.createBucket({
|
||||||
projectId: projectId(p),
|
projectId: await projectId(p),
|
||||||
requestBody: {
|
requestBody: {
|
||||||
name,
|
name,
|
||||||
slug: slug ?? slugify(name),
|
slug: slug ?? slugify(name),
|
||||||
@@ -208,7 +266,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ bucket, projectId: p, q, limit, offset }) => {
|
async ({ bucket, projectId: p, q, limit, offset }) => {
|
||||||
try {
|
try {
|
||||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
const bucketId = await bucketIdFor(await projectId(p), bucket);
|
||||||
return ok(await StorageService.listBucketObjects({ bucketId, q, limit, offset }));
|
return ok(await StorageService.listBucketObjects({ bucketId, q, limit, offset }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
@@ -231,10 +289,16 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ bucket, key, content, contentBase64, contentType, projectId: p }) => {
|
async ({ bucket, key, content, contentBase64, contentType, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
const bucketId = await bucketIdFor(await projectId(p), bucket);
|
||||||
let blob: Blob;
|
let blob: Blob;
|
||||||
if (contentBase64 !== undefined) {
|
if (contentBase64 !== undefined) {
|
||||||
blob = new Blob([Buffer.from(contentBase64, "base64")], {
|
// Buffer.from(..., "base64") silently drops invalid chars — strict-
|
||||||
|
// validate so corrupt input can't silently store garbage bytes.
|
||||||
|
const clean = contentBase64.replace(/\s/g, "");
|
||||||
|
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(clean) || clean.length % 4 !== 0) {
|
||||||
|
throw new Error("contentBase64 is not valid base64");
|
||||||
|
}
|
||||||
|
blob = new Blob([Buffer.from(clean, "base64")], {
|
||||||
type: contentType ?? "application/octet-stream",
|
type: contentType ?? "application/octet-stream",
|
||||||
});
|
});
|
||||||
} else if (content !== undefined) {
|
} else if (content !== undefined) {
|
||||||
@@ -266,7 +330,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ bucket, key, projectId: p }) => {
|
async ({ bucket, key, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
const bucketId = await bucketIdFor(await projectId(p), bucket);
|
||||||
const res = await apiFetch(`/buckets/${bucketId}/objects/${encodeURIComponent(key)}`);
|
const res = await apiFetch(`/buckets/${bucketId}/objects/${encodeURIComponent(key)}`);
|
||||||
const type = res.headers.get("content-type") ?? "application/octet-stream";
|
const type = res.headers.get("content-type") ?? "application/octet-stream";
|
||||||
if (TEXT_TYPES.test(type)) {
|
if (TEXT_TYPES.test(type)) {
|
||||||
@@ -292,7 +356,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ bucket, key, projectId: p }) => {
|
async ({ bucket, key, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
const bucketId = await bucketIdFor(projectId(p), bucket);
|
const bucketId = await bucketIdFor(await projectId(p), bucket);
|
||||||
await StorageService.deleteBucketObject({ bucketId, objectKey: key });
|
await StorageService.deleteBucketObject({ bucketId, objectKey: key });
|
||||||
return ok({ deleted: key });
|
return ok({ deleted: key });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -309,7 +373,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ projectId: p }) => {
|
async ({ projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
return ok(await ProjectsService.listApiKeys({ projectId: projectId(p) }));
|
return ok(await ProjectsService.listApiKeys({ projectId: await projectId(p) }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -324,7 +388,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ name, projectId: p }) => {
|
async ({ name, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
return ok(await ProjectsService.createApiKey({ projectId: projectId(p), requestBody: { name } }));
|
return ok(await ProjectsService.createApiKey({ projectId: await projectId(p), requestBody: { name } }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -339,7 +403,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ apiKeyId, projectId: p }) => {
|
async ({ apiKeyId, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
await ProjectsService.revokeApiKey({ projectId: projectId(p), apiKeyId });
|
await ProjectsService.revokeApiKey({ projectId: await projectId(p), apiKeyId });
|
||||||
return ok({ revoked: apiKeyId });
|
return ok({ revoked: apiKeyId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
@@ -362,7 +426,7 @@ server.registerTool(
|
|||||||
async ({ projectId: p, q, action, limit, offset }) => {
|
async ({ projectId: p, q, action, limit, offset }) => {
|
||||||
try {
|
try {
|
||||||
return ok(
|
return ok(
|
||||||
await ProjectsService.listAuditLogs({ projectId: projectId(p), q, action, limit, offset }),
|
await ProjectsService.listAuditLogs({ projectId: await projectId(p), q, action, limit, offset }),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
@@ -383,7 +447,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ projectId: p, days }) => {
|
async ({ projectId: p, days }) => {
|
||||||
try {
|
try {
|
||||||
return ok(await TelemetryService.listTelemetryIssues({ projectId: projectId(p), days }));
|
return ok(await TelemetryService.listTelemetryIssues({ projectId: await projectId(p), days }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -407,7 +471,7 @@ server.registerTool(
|
|||||||
try {
|
try {
|
||||||
return ok(
|
return ok(
|
||||||
await TelemetryService.listTelemetryEvents({
|
await TelemetryService.listTelemetryEvents({
|
||||||
projectId: projectId(p),
|
projectId: await projectId(p),
|
||||||
type,
|
type,
|
||||||
component,
|
component,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
@@ -432,7 +496,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ projectId: p, window }) => {
|
async ({ projectId: p, window }) => {
|
||||||
try {
|
try {
|
||||||
return ok(await TelemetryService.getTelemetryStats({ projectId: projectId(p), window }));
|
return ok(await TelemetryService.getTelemetryStats({ projectId: await projectId(p), window }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -452,7 +516,7 @@ server.registerTool(
|
|||||||
async ({ projectId: p, name, window }) => {
|
async ({ projectId: p, name, window }) => {
|
||||||
try {
|
try {
|
||||||
return ok(
|
return ok(
|
||||||
await TelemetryService.getTelemetryMetricSeries({ projectId: projectId(p), name, window }),
|
await TelemetryService.getTelemetryMetricSeries({ projectId: await projectId(p), name, window }),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
@@ -474,7 +538,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ projectId: p }) => {
|
async ({ projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
return ok(await IntegrationsService.listIntegrations({ projectId: projectId(p) }));
|
return ok(await IntegrationsService.listIntegrations({ projectId: await projectId(p) }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -498,7 +562,7 @@ server.registerTool(
|
|||||||
try {
|
try {
|
||||||
return ok(
|
return ok(
|
||||||
await IntegrationsService.createIntegration({
|
await IntegrationsService.createIntegration({
|
||||||
projectId: projectId(p),
|
projectId: await projectId(p),
|
||||||
requestBody: {
|
requestBody: {
|
||||||
name,
|
name,
|
||||||
type: CreateIntegrationRequest.type.RYBBIT,
|
type: CreateIntegrationRequest.type.RYBBIT,
|
||||||
@@ -522,7 +586,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ integrationId, projectId: p }) => {
|
async ({ integrationId, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
await IntegrationsService.deleteIntegration({ projectId: projectId(p), integrationId });
|
await IntegrationsService.deleteIntegration({ projectId: await projectId(p), integrationId });
|
||||||
return ok({ deleted: integrationId });
|
return ok({ deleted: integrationId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
@@ -538,7 +602,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ integrationId, projectId: p }) => {
|
async ({ integrationId, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
return ok(await IntegrationsService.testIntegration({ projectId: projectId(p), integrationId }));
|
return ok(await IntegrationsService.testIntegration({ projectId: await projectId(p), integrationId }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -561,7 +625,7 @@ server.registerTool(
|
|||||||
try {
|
try {
|
||||||
return ok(
|
return ok(
|
||||||
await IntegrationsService.getIntegrationAnalytics({
|
await IntegrationsService.getIntegrationAnalytics({
|
||||||
projectId: projectId(p),
|
projectId: await projectId(p),
|
||||||
integrationId,
|
integrationId,
|
||||||
site,
|
site,
|
||||||
days,
|
days,
|
||||||
@@ -603,7 +667,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ projectId: p }) => {
|
async ({ projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
return ok(await WebhooksService.listWebhooks({ projectId: projectId(p) }));
|
return ok(await WebhooksService.listWebhooks({ projectId: await projectId(p) }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -627,7 +691,7 @@ server.registerTool(
|
|||||||
try {
|
try {
|
||||||
return ok(
|
return ok(
|
||||||
await WebhooksService.createWebhook({
|
await WebhooksService.createWebhook({
|
||||||
projectId: projectId(p),
|
projectId: await projectId(p),
|
||||||
requestBody: { url, secret, events, enabled },
|
requestBody: { url, secret, events, enabled },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -655,7 +719,7 @@ server.registerTool(
|
|||||||
try {
|
try {
|
||||||
return ok(
|
return ok(
|
||||||
await WebhooksService.updateWebhook({
|
await WebhooksService.updateWebhook({
|
||||||
projectId: projectId(p),
|
projectId: await projectId(p),
|
||||||
webhookId,
|
webhookId,
|
||||||
requestBody: { url, secret, events, enabled },
|
requestBody: { url, secret, events, enabled },
|
||||||
}),
|
}),
|
||||||
@@ -674,7 +738,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ webhookId, projectId: p }) => {
|
async ({ webhookId, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
await WebhooksService.deleteWebhook({ projectId: projectId(p), webhookId });
|
await WebhooksService.deleteWebhook({ projectId: await projectId(p), webhookId });
|
||||||
return ok({ deleted: webhookId });
|
return ok({ deleted: webhookId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
@@ -696,7 +760,7 @@ server.registerTool(
|
|||||||
try {
|
try {
|
||||||
return ok(
|
return ok(
|
||||||
await WebhooksService.listWebhookDeliveries({
|
await WebhooksService.listWebhookDeliveries({
|
||||||
projectId: projectId(p),
|
projectId: await projectId(p),
|
||||||
webhookId,
|
webhookId,
|
||||||
limit,
|
limit,
|
||||||
}),
|
}),
|
||||||
@@ -715,7 +779,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ webhookId, projectId: p }) => {
|
async ({ webhookId, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
return ok(await WebhooksService.testWebhook({ projectId: projectId(p), webhookId }));
|
return ok(await WebhooksService.testWebhook({ projectId: await projectId(p), webhookId }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -740,7 +804,7 @@ server.registerTool(
|
|||||||
if (!version && !ref) throw new Error("pass version or ref");
|
if (!version && !ref) throw new Error("pass version or ref");
|
||||||
return ok(
|
return ok(
|
||||||
await WebhooksService.createDeployMarker({
|
await WebhooksService.createDeployMarker({
|
||||||
projectId: projectId(p),
|
projectId: await projectId(p),
|
||||||
requestBody: { version, ref, environment, note },
|
requestBody: { version, ref, environment, note },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -764,7 +828,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ projectId: p }) => {
|
async ({ projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
return ok(await AutomationService.listScheduledJobs({ projectId: projectId(p) }));
|
return ok(await AutomationService.listScheduledJobs({ projectId: await projectId(p) }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -792,7 +856,7 @@ server.registerTool(
|
|||||||
try {
|
try {
|
||||||
return ok(
|
return ok(
|
||||||
await AutomationService.createScheduledJob({
|
await AutomationService.createScheduledJob({
|
||||||
projectId: projectId(p),
|
projectId: await projectId(p),
|
||||||
requestBody: { name, schedule, url, secret, payload, enabled },
|
requestBody: { name, schedule, url, secret, payload, enabled },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -821,7 +885,7 @@ server.registerTool(
|
|||||||
try {
|
try {
|
||||||
return ok(
|
return ok(
|
||||||
await AutomationService.updateScheduledJob({
|
await AutomationService.updateScheduledJob({
|
||||||
projectId: projectId(p),
|
projectId: await projectId(p),
|
||||||
jobId,
|
jobId,
|
||||||
requestBody: { name, schedule, url, secret, payload, enabled },
|
requestBody: { name, schedule, url, secret, payload, enabled },
|
||||||
}),
|
}),
|
||||||
@@ -840,7 +904,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ jobId, projectId: p }) => {
|
async ({ jobId, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
await AutomationService.deleteScheduledJob({ projectId: projectId(p), jobId });
|
await AutomationService.deleteScheduledJob({ projectId: await projectId(p), jobId });
|
||||||
return ok({ deleted: jobId });
|
return ok({ deleted: jobId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
@@ -861,7 +925,7 @@ server.registerTool(
|
|||||||
async ({ jobId, limit, projectId: p }) => {
|
async ({ jobId, limit, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
return ok(
|
return ok(
|
||||||
await AutomationService.listScheduledJobRuns({ projectId: projectId(p), jobId, limit }),
|
await AutomationService.listScheduledJobRuns({ projectId: await projectId(p), jobId, limit }),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
@@ -877,7 +941,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ jobId, projectId: p }) => {
|
async ({ jobId, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
return ok(await AutomationService.runScheduledJob({ projectId: projectId(p), jobId }));
|
return ok(await AutomationService.runScheduledJob({ projectId: await projectId(p), jobId }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -939,20 +1003,65 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Commands that would move a secret into the response are denied outright —
|
// vault_exec allow-list — read/non-destructive commands an agent may run
|
||||||
// the CLI is the broker; the model sees results, not credentials.
|
// under an injected secret. Deny-lists lose (a colon-form `secrets:get`
|
||||||
const EXEC_DENY_FIRST = new Set(["vault", "secrets", "inject", "agent", "login", "logout", "use"]);
|
// already bypassed a pair-match once); only these verbs execute.
|
||||||
const EXEC_DENY_PAIR = new Set(["keys:create"]);
|
// Commands touching host files (upload/download --out, secrets import) and
|
||||||
|
// anything that moves a secret into output are intentionally absent.
|
||||||
|
const EXEC_ALLOW = new Set([
|
||||||
|
"whoami",
|
||||||
|
"context",
|
||||||
|
"orgs:list",
|
||||||
|
"projects:list",
|
||||||
|
"buckets:list",
|
||||||
|
"objects:list",
|
||||||
|
"documents:list",
|
||||||
|
"jobs:list",
|
||||||
|
"jobs:runs",
|
||||||
|
"audit:list",
|
||||||
|
"keys:list",
|
||||||
|
"events:send",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Flags that could redirect output into host files — none of the allowed
|
||||||
|
// commands need them, so reject the whole set defensively.
|
||||||
|
const EXEC_ARG_DENY = /^--?(out|file|path|dir|env-file|output|write)\b/;
|
||||||
|
|
||||||
|
// Env names that must never carry an injected secret — preload/runtime
|
||||||
|
// hooks and interpreter variables turn an env injection into code exec
|
||||||
|
// (NODE_OPTIONS, LD_*, BASH_ENV, PYTHONPATH, …). PRIMORA_* stays allowed:
|
||||||
|
// the injected VALUE comes from the vault, and those names are its purpose.
|
||||||
|
const ENV_NAME_DENY =
|
||||||
|
/^(NODE_|LD_|DYLD_|BASH|ENV$|IFS$|PATH$|HOME$|SHELL$|CDPATH$|SHELLOPTS$|BASHOPTS$|PYTHON|PERL|RUBY|GIT_|SSL_CERT|XDG_)/;
|
||||||
|
|
||||||
|
function execAllowed(raw: string[]): { args: string[]; verb: string } {
|
||||||
|
// Normalize colon-form: ["objects:list","b"] ≡ ["objects","list","b"].
|
||||||
|
const args = [...raw];
|
||||||
|
if (args[0].includes(":") && !args[0].startsWith("-")) {
|
||||||
|
args.splice(0, 1, ...args[0].split(":"));
|
||||||
|
}
|
||||||
|
const verb = args.length > 1 && !args[1].startsWith("-") ? `${args[0]}:${args[1]}` : args[0];
|
||||||
|
if (!EXEC_ALLOW.has(verb)) {
|
||||||
|
throw new Error(`denied: "${verb}" is not on the vault_exec allow-list (read-only commands only)`);
|
||||||
|
}
|
||||||
|
for (const a of args) {
|
||||||
|
if (a.includes("\0")) throw new Error("denied: NUL byte in argument");
|
||||||
|
if (a.includes("..") || EXEC_ARG_DENY.test(a)) {
|
||||||
|
throw new Error(`denied: argument "${a}" could redirect to host files`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { args, verb };
|
||||||
|
}
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"primora_vault_exec",
|
"primora_vault_exec",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Run a primora CLI command with a vault secret injected into its environment (default secret: PRIMORA_API_KEY). The secret never appears in arguments or the response — only the command output is returned.",
|
"Run a read-only primora CLI command with a vault secret injected into its environment (default secret: PRIMORA_API_KEY). Allow-listed commands only; the secret never appears in arguments or the response.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
command: z
|
command: z
|
||||||
.array(z.string())
|
.array(z.string())
|
||||||
.describe('primora arguments, e.g. ["objects","list","mybucket"] — vault/secrets/inject/agent/login/logout/use and keys:create are denied'),
|
.describe('primora arguments, e.g. ["objects","list","mybucket"] — allow-listed read commands only'),
|
||||||
secret: z
|
secret: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -962,17 +1071,16 @@ server.registerTool(
|
|||||||
async ({ command, secret }) => {
|
async ({ command, secret }) => {
|
||||||
try {
|
try {
|
||||||
if (!command.length) throw new Error("empty command");
|
if (!command.length) throw new Error("empty command");
|
||||||
const first = command[0];
|
const { args } = execAllowed(command);
|
||||||
const pair = `${first}:${command[1] ?? ""}`;
|
|
||||||
if (EXEC_DENY_FIRST.has(first) || EXEC_DENY_PAIR.has(pair) || EXEC_DENY_PAIR.has(first)) {
|
|
||||||
throw new Error(`denied: "${first}" would expose credentials or config`);
|
|
||||||
}
|
|
||||||
const secretName = secret ?? "PRIMORA_API_KEY";
|
const secretName = secret ?? "PRIMORA_API_KEY";
|
||||||
|
if (!/^[A-Z_][A-Z0-9_]{0,63}$/.test(secretName) || ENV_NAME_DENY.test(secretName)) {
|
||||||
|
throw new Error(`denied: "${secretName}" is not a safe env name to inject`);
|
||||||
|
}
|
||||||
const got = runCli(["secrets", "get", secretName]);
|
const got = runCli(["secrets", "get", secretName]);
|
||||||
if (got.code !== 0) {
|
if (got.code !== 0) {
|
||||||
throw new Error(got.stderr.trim() || `vault locked or "${secretName}" missing`);
|
throw new Error(got.stderr.trim() || `vault locked or "${secretName}" missing`);
|
||||||
}
|
}
|
||||||
const r = runCli(command, { [secretName]: got.stdout.trimEnd() });
|
const r = runCli(args, { [secretName]: got.stdout.trimEnd() });
|
||||||
return ok({ exitCode: r.code, stdout: r.stdout, stderr: r.stderr });
|
return ok({ exitCode: r.code, stdout: r.stdout, stderr: r.stderr });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
@@ -992,7 +1100,7 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
async ({ projectId: p }) => {
|
async ({ projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
return ok(await SecretsService.listProjectSecrets({ projectId: projectId(p) }));
|
return ok(await SecretsService.listProjectSecrets({ projectId: await projectId(p) }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(e);
|
return fail(e);
|
||||||
}
|
}
|
||||||
@@ -1015,7 +1123,7 @@ server.registerTool(
|
|||||||
async ({ name, value, url, notes, projectId: p }) => {
|
async ({ name, value, url, notes, projectId: p }) => {
|
||||||
try {
|
try {
|
||||||
const res = await SecretsService.setProjectSecret({
|
const res = await SecretsService.setProjectSecret({
|
||||||
projectId: projectId(p),
|
projectId: await projectId(p),
|
||||||
name,
|
name,
|
||||||
requestBody: { value, url, notes },
|
requestBody: { value, url, notes },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${MAILPIT_HTTP_PORT}:8025"
|
- "${MAILPIT_HTTP_PORT}:8025"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8025/ >/dev/null 2>&1 || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8025/ >/dev/null 2>&1 || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
+6
-2
@@ -48,6 +48,10 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- backend_storage:/data/storage
|
- backend_storage:/data/storage
|
||||||
|
# Uncomment for FUNCTIONS_DRIVER=docker — sibling containers for
|
||||||
|
# sandboxed function runs. Socket access is root-equivalent on the
|
||||||
|
# host; leave commented unless you run untrusted function code.
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health/liveness || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health/liveness || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
@@ -100,7 +104,7 @@ services:
|
|||||||
auth:
|
auth:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost/ >/dev/null 2>&1 || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -120,7 +124,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost/nginx-health >/dev/null 2>&1 || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/nginx-health >/dev/null 2>&1 || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
Generated
+21
-1
@@ -38,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"apps/cli": {
|
"apps/cli": {
|
||||||
"name": "@primora/cli",
|
"name": "@primora/cli",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.8.1",
|
"@clack/prompts": "^1.8.1",
|
||||||
"@noble/ciphers": "^2.4.0",
|
"@noble/ciphers": "^2.4.0",
|
||||||
@@ -78,6 +78,8 @@
|
|||||||
"name": "@primora/frontend",
|
"name": "@primora/frontend",
|
||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/geist": "^5.3.0",
|
||||||
|
"@fontsource/geist-mono": "^5.3.0",
|
||||||
"@primora/api-client": "0.5.0",
|
"@primora/api-client": "0.5.0",
|
||||||
"@primora/shared-types": "0.5.0",
|
"@primora/shared-types": "0.5.0",
|
||||||
"better-auth": "^1.5.6",
|
"better-auth": "^1.5.6",
|
||||||
@@ -2341,6 +2343,24 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/geist": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/geist/-/geist-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-u03ftlfVwb05uijq2jGDJ03/HKGqMKaNFk7KOhkpBUex8+BhrAOfMs/XciJ2UUNUX9fEsZbZQld/RWMA2oFU7g==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/geist-mono": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/geist-mono/-/geist-mono-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.11",
|
"version": "1.19.11",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||||
|
|||||||
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user