mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61e8c696c9 | ||
|
|
207486a53d | ||
|
|
aab4dd3bfd | ||
|
|
9dd6b40934 | ||
|
|
dcc676bd52 | ||
|
|
8ff8a5fb26 | ||
|
|
9707f307a1 | ||
|
|
02d994c53b | ||
|
|
0a3f4e2a57 | ||
|
|
03acb75f66 | ||
|
|
a71baa49cd | ||
|
|
89f3d84234 | ||
|
|
182b0c49c2 | ||
|
|
2d6135121e | ||
|
|
3b90dae767 | ||
|
|
236357facb | ||
|
|
9ae1c22af0 | ||
|
|
edef002beb | ||
|
|
fd36389328 | ||
|
|
a84049b8fc | ||
|
|
c094afad65 | ||
|
|
0a4ff1e6ae | ||
|
|
32587c8063 | ||
|
|
3aca5bd1e0 | ||
|
|
f747b33b46 | ||
|
|
45a1621909 | ||
|
|
2f4de543a9 | ||
|
|
345cf7918b | ||
|
|
8892c6dc43 | ||
|
|
f9e7002048 | ||
|
|
585bb04ab2 | ||
|
|
31bc23386b |
No files matched your search
@@ -50,6 +50,31 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1
|
||||
BACKEND_INTERNAL_URL=http://backend:8080
|
||||
BACKEND_STORAGE_ROOT=/data/storage
|
||||
|
||||
# Object storage driver: "local" (default, uses BACKEND_STORAGE_ROOT) or "s3".
|
||||
# For s3, point S3_ENDPOINT at any S3-compatible API (AWS, MinIO, Garage, R2).
|
||||
BACKEND_STORAGE_DRIVER=local
|
||||
#S3_ENDPOINT=http://minio:9000
|
||||
#S3_REGION=us-east-1
|
||||
#S3_BUCKET=primora-objects
|
||||
#S3_ACCESS_KEY_ID=
|
||||
#S3_SECRET_ACCESS_KEY=
|
||||
#S3_PREFIX=
|
||||
#S3_PATH_STYLE=true
|
||||
# Client-facing S3 endpoint for presigned URLs — set when S3_ENDPOINT is an
|
||||
# internal address (e.g. http://minio:9000 inside compose) that browsers
|
||||
# can't reach. Presigned URLs are valid only at the host they were minted for.
|
||||
#S3_PUBLIC_ENDPOINT=https://objects.example.com
|
||||
|
||||
# Functions execution. "exec" runs the runtime binary (bun/deno) directly on
|
||||
# the backend host — simple, but function code inherits backend privileges.
|
||||
# "docker" runs each invoke in an ephemeral locked-down container (no
|
||||
# network, read-only fs, 128 MiB mem, non-root) and requires the Docker
|
||||
# socket mounted into the backend plus the runtime images pulled.
|
||||
#FUNCTIONS_DRIVER=exec
|
||||
#FUNCTIONS_RUNTIME=auto
|
||||
#FUNCTIONS_TIMEOUT_SECONDS=30
|
||||
#FUNCTIONS_MAX_OUTPUT_BYTES=65536
|
||||
|
||||
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
|
||||
# values win over these env vars; deleting the in-app value reverts here.
|
||||
RESEND_API_KEY=
|
||||
|
||||
+136
-10
@@ -59,17 +59,20 @@ jobs:
|
||||
fi
|
||||
|
||||
images:
|
||||
name: Build + push ${{ matrix.app }}
|
||||
name: Build + push ${{ matrix.app }} (${{ matrix.platform }})
|
||||
needs: verify
|
||||
if: needs.verify.outputs.needed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Native runners per arch — QEMU emulation SIGILLs on the current
|
||||
# node:20-alpine arm64 layers (uncaught target signal 4) and wedges
|
||||
# the build step indefinitely.
|
||||
runs-on: ${{ matrix.platform == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
app: [backend, auth, frontend]
|
||||
platform: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
# ghcr.io requires lowercase repo paths; the owner login may be mixed-case.
|
||||
- name: Compute lowercase GHCR owner
|
||||
@@ -84,13 +87,38 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: apps/${{ matrix.app }}/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }}
|
||||
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:latest
|
||||
cache-from: type=gha,scope=${{ matrix.app }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.app }}
|
||||
tags: ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }}-${{ matrix.platform }}
|
||||
cache-from: type=gha,scope=${{ matrix.app }}-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.app }}-${{ matrix.platform }}
|
||||
|
||||
# Joins the per-arch images into the real version + latest tags.
|
||||
manifests:
|
||||
name: Image manifests
|
||||
needs: [verify, images]
|
||||
if: needs.verify.outputs.needed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Compute lowercase GHCR owner
|
||||
run: echo "GHCR_OWNER=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_ENV"
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Create multi-arch manifests
|
||||
env:
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
run: |
|
||||
set -e
|
||||
for app in backend auth frontend; do
|
||||
img="ghcr.io/${GHCR_OWNER}/primora-${app}"
|
||||
docker buildx imagetools create \
|
||||
-t "$img:$VERSION" -t "$img:latest" \
|
||||
"$img:$VERSION-amd64" "$img:$VERSION-arm64"
|
||||
done
|
||||
|
||||
cli-binaries:
|
||||
name: Compile ${{ matrix.name }}
|
||||
@@ -146,6 +174,11 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
- name: Build Tauri bundles
|
||||
# Updater artifacts are minisign-signed; without the key the build
|
||||
# still succeeds but emits no .sig files (updater can't verify them).
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: npm run bundle --workspace @primora/desktop
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -155,8 +188,11 @@ jobs:
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.deb
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.rpm
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.dmg
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.app.tar.gz
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.msi
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.exe
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.nsis.zip
|
||||
apps/desktop/src-tauri/target/release/bundle/**/*.sig
|
||||
if-no-files-found: error
|
||||
|
||||
npm-packages:
|
||||
@@ -184,7 +220,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: GitHub Release
|
||||
needs: [verify, images, cli-binaries, desktop, npm-packages]
|
||||
needs: [verify, manifests, cli-binaries, desktop, npm-packages]
|
||||
if: needs.verify.outputs.needed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -225,6 +261,48 @@ jobs:
|
||||
flag
|
||||
' CHANGELOG.md > notes.md
|
||||
[ -s notes.md ] || echo "Release ${VERSION}" > notes.md
|
||||
# Tauri updater manifest: one platform entry per signed updater
|
||||
# artifact (AppImage.sig / app.tar.gz.sig / nsis.zip.sig). Missing
|
||||
# sigs mean TAURI_SIGNING_PRIVATE_KEY isn't set — then there is no
|
||||
# manifest and no update, which is correct (never ship unsigned).
|
||||
- name: Build updater manifest (latest.json)
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, glob
|
||||
base = "https://github.com/Dvorinka/Primora/releases/download/%s" % os.environ["VERSION"]
|
||||
platforms = {}
|
||||
for sig in glob.glob("dist-release/**/*.sig", recursive=True):
|
||||
asset = sig[:-4]
|
||||
name = os.path.basename(asset)
|
||||
if name.endswith(".AppImage"):
|
||||
plat = "linux-x86_64"
|
||||
elif name.endswith(".app.tar.gz"):
|
||||
plat = "darwin-aarch64" if "aarch64" in name else "darwin-x86_64"
|
||||
if "aarch64" not in name and "x86_64" not in name:
|
||||
plat = "darwin-aarch64" # macos-latest is arm64
|
||||
elif name.endswith(".nsis.zip"):
|
||||
plat = "windows-x86_64"
|
||||
else:
|
||||
continue
|
||||
platforms[plat] = {
|
||||
"signature": open(sig).read().strip(),
|
||||
"url": f"{base}/{name}",
|
||||
}
|
||||
manifest = {
|
||||
"version": os.environ["VERSION"],
|
||||
"notes": "See the release notes.",
|
||||
"pub_date": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"platforms": platforms,
|
||||
}
|
||||
if platforms:
|
||||
with open("dist-release/latest.json", "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
print("latest.json:", ", ".join(platforms))
|
||||
else:
|
||||
print("no signed updater artifacts — skipping latest.json")
|
||||
PY
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -234,6 +312,54 @@ jobs:
|
||||
mapfile -t files < <(find dist-release -type f)
|
||||
[ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; }
|
||||
gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}"
|
||||
# The manifest must be fetchable at /releases/latest/download/latest.json
|
||||
if [ -f dist-release/latest.json ]; then
|
||||
gh release upload "$VERSION" dist-release/latest.json --clobber
|
||||
fi
|
||||
|
||||
# Software bill of materials — CycloneDX JSON per published image plus
|
||||
# one for the source tree (npm + Go modules). Attached to the release
|
||||
# next to the binaries; downstream consumers can verify provenance.
|
||||
sbom:
|
||||
name: SBOMs
|
||||
needs: [verify, release]
|
||||
if: needs.verify.outputs.needed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
env:
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: anchore/sbom-action@v0
|
||||
with:
|
||||
image: ghcr.io/${{ github.repository_owner }}/primora-backend:${{ needs.verify.outputs.version }}
|
||||
format: cyclonedx-json
|
||||
output-file: dist-release/sbom-backend.cdx.json
|
||||
- uses: anchore/sbom-action@v0
|
||||
with:
|
||||
image: ghcr.io/${{ github.repository_owner }}/primora-auth:${{ needs.verify.outputs.version }}
|
||||
format: cyclonedx-json
|
||||
output-file: dist-release/sbom-auth.cdx.json
|
||||
- uses: anchore/sbom-action@v0
|
||||
with:
|
||||
image: ghcr.io/${{ github.repository_owner }}/primora-frontend:${{ needs.verify.outputs.version }}
|
||||
format: cyclonedx-json
|
||||
output-file: dist-release/sbom-frontend.cdx.json
|
||||
- uses: anchore/sbom-action@v0
|
||||
with:
|
||||
path: .
|
||||
format: cyclonedx-json
|
||||
output-file: dist-release/sbom-source.cdx.json
|
||||
- name: Attach SBOMs to the release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "$VERSION" dist-release/sbom-*.cdx.json --clobber
|
||||
|
||||
npm-publish:
|
||||
name: Publish to npm
|
||||
|
||||
@@ -2,6 +2,61 @@
|
||||
|
||||
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.0] - 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Agent setup prompt** — a "Set up with an AI agent" dialog that mints a fresh `write`-scoped API key (ingest + reads + non-admin mutations, never key-minting) and renders a copyable prompt handing a coding agent everything it needs: the deployment endpoint, project id, the key itself, plus tasks to instrument error/metric/log/heartbeat reporting per component (`POST /api/v1/ingest` — works from any language), register the app's databases via `db-connections` (postgres → cassandra), wire the MCP server / CLI for future agents, and verify with a test event + deploy marker. Opens at the end of the new-project onboarding and is re-reachable from the project dashboard and Projects page; keys are shown only inside the prompt (revoke under Settings → API keys).
|
||||
- **Local secrets vault in the CLI** (`@primora/cli` 0.6.0) — `primora vault init/status` and `primora secrets set|get|list|rm|import` manage an encrypted `~/.config/primora/vault.bin` (Argon2id 64 MiB/3/4 → XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes). Entries carry value + optional `url`/`notes` metadata. Password via `PRIMORA_VAULT_PASSWORD`, `--password-file`, or hidden prompt; file path overridable with `PRIMORA_VAULT`. Clean-room format — no external vault compatibility.
|
||||
- **Vault sessions** — `primora vault unlock --ttl 900` proves the password once and writes a TTL'd `vault.session` (0600); agents and scripts use the vault without the password. `primora vault lock` revokes immediately.
|
||||
- **`primora agent` credential broker** — wraps a child process with dummy tokens and a loopback base URL; the proxy attaches the real secret on the wire per grant (TTL, cap 3600 s). Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`, `github`, `npm`; `--upstream name=https://…` covers arbitrary Bearer APIs. Base-URL broker, not TLS MITM — tools must honor a base-URL override.
|
||||
- **`primora inject`** — runs a child process with vault secrets in its environment (`--all`, or `--env-file` templates containing `primora://NAME` refs). Nothing on disk, nothing in argv.
|
||||
- **`primora stack:*` operator group** — `up`, `down`, `status`, `logs [service]`, `pull`, `backup` wrap `docker compose` in `--dir` (default cwd). `stack up --vault` materializes `.env` from the vault for the duration of the run (refuses to clobber an existing `.env` without `--force`, which swaps and restores it). `stack backup` writes a `pg_dump -Fc` of the platform database to `./backups`.
|
||||
- **Vault MCP tools** (`@primora/mcp`) — `primora_vault_status`, `primora_vault_list` (metadata only — values are never returned), and `primora_vault_exec` which runs `primora` commands with a secret injected into the child environment; `vault`/`secrets`/`inject`/`agent`/`login`/`logout`/`use` and `keys:create` are denied so no response can carry a credential. (Hardened to a strict allow-list — see Security.)
|
||||
- **Secrets Vault window in the desktop app** (`@primora/desktop`) — tray "Secrets Vault…" opens a dedicated window (`ui/vault.html`) covering init, timed unlock/lock with live countdown, secret list with copy-to-clipboard (values never displayed), add/delete, and `.env` import. IPC shells out to the `primora` binary (`PRIMORA_CLI` overrides), so desktop and terminal share one vault file and one crypto implementation; a missing CLI shows install guidance. Also reachable from the connect screen.
|
||||
- **Project vault — server-side secrets** (`core.project_secrets`, migration 00011) — per-project secrets encrypted AES-256-GCM at rest via the existing encryptor. API: `GET/PUT/DELETE /projects/:projectID/secrets[/:name]` plus `POST …/reveal`. Lists return metadata only (name, url, notes, timestamps); reveal decrypts explicitly and is audit-logged (`secret.set`/`secret.revealed`/`secret.deleted`). Scheduled-job payloads reference secrets as `secret://NAME`, resolved to plaintext at delivery — refs are stored, values are not; unresolved or unconfigured refs fail the run loudly.
|
||||
- **Vault page in the dashboard** — project secrets table with create/update, `.env` import, delete, and copy-to-clipboard reveal; plus a "This machine" card when running inside the desktop shell that drives the local vault through the same Tauri IPC commands as the vault window.
|
||||
- **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values.
|
||||
- **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext.
|
||||
- **Realtime channel subscriptions** (`@primora/client`) — `createRealtimeClient({ endpoint, key, projectId })` opens the project SSE stream (`/realtime/stream`) via fetch + ReadableStream (EventSource can't send `X-API-Key`). `channel("documents").on("created", cb)` subscribes by event namespace (`document`/`object`/`issue`/`deploy`, `"*"` for all); auto-reconnects with capped exponential backoff; `onStateChange` reports `connecting|open|reconnecting|closed`.
|
||||
- **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components.
|
||||
- **Inbound webhooks** (`core.inbound_hooks`, migration 00013) — public ingest endpoints `POST /api/v1/hooks/<token>` (the token is the credential). `event` mode republishes the body as `inbound.received` domain events through the signed dispatcher + realtime; `job` mode triggers a scheduled-job run with the received body as its payload (`triggered_by: "hook"`). Optional `secret` requires `X-Primora-Signature` HMAC-SHA256 — the same scheme outbound deliveries use. Bodies capped at 256 KiB. Managed from Integrations → Webhooks.
|
||||
- **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection <slug> --filter … --order …`.
|
||||
- **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test.
|
||||
- **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations.
|
||||
- **S3-compatible object storage backend** — `BACKEND_STORAGE_DRIVER=s3` swaps the local disk store for any S3-compatible API (AWS, MinIO, Garage, R2) via a stdlib-only SigV4 implementation — no AWS SDK dependency. `S3_ENDPOINT`/`S3_REGION`/`S3_BUCKET`/credentials configure it; `S3_PATH_STYLE` (default on) covers MinIO/Garage, off for AWS virtual-hosted. The configured bucket is created on startup if missing; objects stream with computed SHA-256, `storage_path` records `s3://bucket/key`. Verified live end-to-end (bucket → put → get → copy → list → delete).
|
||||
- **Functions** (`core.functions` + `core.function_runs`, migration 00015) — project-scoped JS/TS executed by a runtime binary on the backend host (`bun` or `deno`, per function). The JSON payload arrives on stdin and as `PRIMORA_PAYLOAD`; stdout/stderr are captured per run (capped at 64 KiB) with status, exit code and duration — `FUNCTIONS_RUNTIME`/`FUNCTIONS_TIMEOUT_SECONDS`/`FUNCTIONS_MAX_OUTPUT_BYTES` configure the runner. API: CRUD under `/projects/:id/functions`, `POST …/invoke` (502 + recorded run on failure), `GET …/runs`. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Code executes with backend privileges — the documented self-hosted trust model.
|
||||
- **Presigned object URLs** — `POST /buckets/:bucketID/object-presigns` mints SigV4 query-signed `GET`/`PUT` URLs (60 s–1 h TTL) so clients transfer large objects directly against S3 without proxying through the backend. Read role for downloads (object must exist — no key probing), write role for uploads. `S3_PUBLIC_ENDPOINT` rewrites the client-facing host for internal `S3_ENDPOINT`s; signatures bind the host so URLs are valid only where they were minted. The local driver returns a clear error — a presigned URL needs a real object API.
|
||||
- **Desktop auto-updater** — `tauri-plugin-updater` wired end to end: minisign keypair, `createUpdaterArtifacts`, `latest.json` manifest built from signed artifacts on every release, and a silent background check on the connect screen offering "Update & restart" (download → verify → install → relaunch). Unsigned artifacts can never install — missing signature means no manifest, not a bypass. CI signs via `TAURI_SIGNING_PRIVATE_KEY` secrets; OS-level signing (notarization/Authenticode) remains open.
|
||||
- **Release SBOMs** — CycloneDX SBOM for each published GHCR image (backend/auth/frontend) plus one for the source tree, attached to every GitHub Release via `anchore/sbom-action`.
|
||||
- **Sandboxed function driver** — `FUNCTIONS_DRIVER=docker` runs every invoke in an ephemeral locked-down container: no network, read-only rootfs, 128 MiB memory, 0.5 CPU, 64 pids, non-root, all capabilities dropped. Source is injected via base64 env (no bind mount — works on Docker Desktop and remote daemons); timeout force-removes the container so nothing lingers. Verified live: code runs, network is unreachable inside, and deadline kills land.
|
||||
- **Client events + presence** — `POST /projects/:id/events` publishes client-authored events (`custom.*` namespace enforced — system types can't be spoofed) through the same fan-out as system events: realtime subscribers, matching webhooks, and `event_pattern` functions all fire. Realtime now tracks presence: `presence.update` {online} broadcasts on every subscribe/unsubscribe (stream-only — never reaches webhooks or functions), `GET …/realtime/presence` reads the count. Client SDK gains `publish()` and `presence()`; CLI gains `events:send`.
|
||||
- **Function triggers** (migration 00016) — three new ways to run a function, all through the same executor and run log. Scheduled jobs accept `function_id` instead of `url` (run status mirrors the exit code); inbound hooks gain `mode: "function"` — the received body becomes the function's stdin payload, acked immediately with the run recorded async; and `event_pattern` on a function (`document.*`, `*`, or an exact type) fires it detached whenever a matching domain event publishes — `{event, project_id, occurred_at, data}` as payload. Runs record trigger source: `manual`/`schedule`/`hook`/`event`. Dashboard: function event-trigger field, job target picker (URL or function), and inbound-hook function mode.
|
||||
- **`GET /api/v1/context`** — actor self-discovery: for API keys returns scopes, key prefix, and the resolved organization + project (id/slug/name). Fixes the API-key onboarding dead end: `primora login --api-key` now validates the key against the instance before saving and seeds org/project context automatically; `primora use`, `context`, and `whoami` work under API-key auth via this endpoint.
|
||||
- **CLI `orgs:rm` / `projects:rm` / `buckets:rm`** — destructive deletes gated behind `--yes` in non-interactive mode, interactive confirm otherwise. `buckets:rm` resolves id/slug/name.
|
||||
- **Webhook wildcard subscriptions** — `events` accepts `*`, `<namespace>.*`, and `custom.*` patterns, matched at dispatch time via SQL `LIKE` — the same semantics function `event_pattern` already used. Client-published `custom.*` events can now be webhooked.
|
||||
|
||||
### Security
|
||||
|
||||
- **`primora_vault_exec` rewritten as an allow-list** — the deny-list was bypassed by colon-form commands (`["secrets:get","X"]` as a single token evaded the pair check), exposing every vault secret; `--remote` variants revealed project secrets; and the `secret` param was used verbatim as an env name, allowing `NODE_OPTIONS=--require <file>` host code execution. Now only a fixed read-mostly command set runs (`whoami`, `context`, `orgs:list`, `projects:list`, `buckets:list`, `objects:list`, `documents:list`, `jobs:list`, `jobs:runs`, `audit:list`, `keys:list`, `events:send`), path/file flags and `..` args are rejected, injected env names must match `^[A-Z_][A-Z0-9_]{0,63}$`, and loader-hook names (`NODE_*`, `LD_*`, `DYLD_*`, `PATH`, `BASH*`, `PYTHON*`, `GIT_*`, `SSL_CERT*`, `XDG_*`, …) are blocked. Verified: every prior bypass denied, allowed commands still inject.
|
||||
- **SSRF hardening on webhook/job URLs** — cloud metadata endpoints (`169.254.169.254`, `fd00:ec2::254`) are excluded from the link-local/private allowance, and pure decimal/hex "bare hostnames" (`2130706433`, `0x7f000001` — non-canonical IP encodings of loopback) are no longer treated as Docker service names.
|
||||
- **Path params now `encodeURIComponent`** in all generated-client consumers (CLI, MCP, dashboard) — `encodeURI` left `?#&` intact, smuggling query segments through `{name}` params (e.g. `secrets/a?reveal=1`).
|
||||
- **Strict base64 in `primora_upload_object`** — `Buffer.from(b64)` silently drops invalid characters; `contentBase64` is now validated (whitespace-tolerant) so corrupt input can't silently store garbage bytes.
|
||||
- **MCP no-creds startup** — a missing config/API key previously threw inside `configureClient`, killing the process mid-handshake (protocol hang, stack on stderr). Startup now degrades: `initialize`/`tools/list` answer normally and every tool call returns the actionable error.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Silent CLI dispatch** — unknown commands, a bare `primora`, and group names without a verb exited 0 with no output. Now: unknown input errors with exit 1 (bare groups list their verbs), bare `primora` prints help.
|
||||
- **Arg-parser numeric coercion** — `cac`/`mri` coerced numeric-looking flag values (`--value "007"` → `7`, `--value ""` → `0`), corrupting secrets and keys. Parsed options are now re-read as raw strings from argv for every non-boolean flag; `--value 007` round-trips intact.
|
||||
- **`pgx.ErrNoRows` → 404** in the shared error mapper — missing resources returned raw "no rows in result set" 500s across all handlers.
|
||||
- **Structured error hygiene** — `pgconn.PgError` codes map to clean statuses (23505 → 409 "resource already exists", 23503 → 409, 22001/54000 → 400 "value too large", 22021 → 400) instead of leaking SQLSTATE/constraint names; validator failures report `field is required`/`field failed <tag> validation`; a new `services.InputError` type maps service-layer input failures to 400 (webhook URL/event-type errors were 500s). Presign on the local driver returns `presign_unsupported` 400.
|
||||
- **`fetch failed` polish** — connection failures report the cause code and suggest `primora context` instead of bare undici errors.
|
||||
- **`login --json`** — the flag now emits JSON on both the session and API-key paths.
|
||||
- **Dashboard fonts vendored** — Geist/Geist Mono now ship via `@fontsource` inside the bundle instead of a Google Fonts `<link>` the nginx CSP blocked (`style-src 'self'`). Removes the runtime Google dependency, works airgapped, and eliminates the console CSP violation.
|
||||
- **Release image builds moved off QEMU** — arm64 `npm ci` under `docker/setup-qemu-action` SIGILLed on current `node:20-alpine` layers (`uncaught target signal 4`) and wedged the build step for over an hour. Images now build per-arch on native runners (`ubuntu-24.04-arm` for arm64) and a `manifests` job joins them into the multi-arch `:<version>`/`:latest` tags.
|
||||
|
||||
## [0.7.1] - 2026-09-23
|
||||
|
||||
### Fixed
|
||||
|
||||
+30
-6
@@ -37,11 +37,18 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
| `DRAGONFLY_URL` | backend → cache DSN | yes |
|
||||
| `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes |
|
||||
| `BETTER_AUTH_SECRET` | auth session/signing secret | yes |
|
||||
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for integration credentials + webhook secrets at rest | production |
|
||||
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for project secrets (Vault page, `secret://` job refs), integration credentials + webhook secrets at rest | production |
|
||||
| `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
|
||||
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
|
||||
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
|
||||
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container | yes |
|
||||
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container (local driver) | yes |
|
||||
| `BACKEND_STORAGE_DRIVER` | `local` (default) or `s3` — S3-compatible object storage | no |
|
||||
| `S3_ENDPOINT` / `S3_REGION` / `S3_BUCKET` | S3 API endpoint (empty endpoint → AWS `s3.<region>.amazonaws.com`), region, bucket | with `s3` |
|
||||
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 credentials | with `s3` |
|
||||
| `S3_PREFIX` / `S3_PATH_STYLE` | optional key prefix; path-style URLs (default `true`, needed by MinIO/Garage — set `false` for AWS virtual-hosted) | no |
|
||||
| `S3_PUBLIC_ENDPOINT` | client-facing S3 endpoint used for presigned URLs when `S3_ENDPOINT` is internal-only (e.g. `http://minio:9000`); signatures bind the public host | no |
|
||||
| `FUNCTIONS_DRIVER` | Function execution driver: `exec` (default — runtime binary on the backend host; code runs with backend privileges) or `docker` — each invoke runs in an ephemeral locked-down container (no network, read-only fs, 128 MiB, non-root). Docker mode needs `/var/run/docker.sock` mounted into the backend (see the commented volume in `docker-compose.yml`) and the runtime images pulled (`oven/bun`, `denoland/deno`). Source code is capped at 256 KiB under the docker driver | no |
|
||||
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` / `FUNCTIONS_MAX_OUTPUT_BYTES` | Functions: runtime binary (`auto` resolves `bun`/`deno` — exec driver only; docker driver uses the matching official image), per-run timeout (default 30 s), captured-output cap (default 64 KiB) | no |
|
||||
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
|
||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
|
||||
@@ -127,10 +134,27 @@ docker compose up -d # 3. rolling recreate; backend migrate
|
||||
|
||||
## Storage constraint
|
||||
|
||||
Object storage is the **local filesystem** (`backend_storage` volume). There
|
||||
is no S3-compatible backend yet — the volume must be on durable, backed-up
|
||||
disk, and a single host serves all objects. Plan capacity accordingly;
|
||||
multi-node deployments need an external object store first.
|
||||
Object storage defaults to the **local filesystem** (`backend_storage`
|
||||
volume) — durable, backed-up disk on a single host. For multi-node or
|
||||
external object stores set `BACKEND_STORAGE_DRIVER=s3` + the `S3_*`
|
||||
variables above (AWS, MinIO, Garage, R2 all work).
|
||||
|
||||
## Functions trust model
|
||||
|
||||
Two drivers, two trust levels:
|
||||
|
||||
- **`exec` (default)** — function code runs as a child process of the
|
||||
backend: same host, same privileges, no sandbox. Treat every project
|
||||
member who can edit a function as holding a shell on the backend host.
|
||||
Appropriate for single-team self-hosted deployments.
|
||||
- **`docker`** — each invoke runs in an ephemeral container: no network,
|
||||
read-only rootfs, 128 MiB memory, half a CPU, 64 pids, non-root, all
|
||||
capabilities dropped. Requires the Docker socket mounted into the
|
||||
backend (commented volume in `docker-compose.yml`) — note the socket is
|
||||
itself root-equivalent on the host, so this isolates *function code*,
|
||||
not operators. Source code capped at 256 KiB. For hostile multi-tenant
|
||||
workloads, a dedicated microVM runtime (Firecracker/gVisor) remains the
|
||||
roadmap item.
|
||||
|
||||
## Observability
|
||||
|
||||
|
||||
@@ -61,9 +61,14 @@ The important ones:
|
||||
| `POSTGRES_*` / `DATABASE_URL` | Postgres connection |
|
||||
| `DRAGONFLY_URL` | Cache/rate-limit store |
|
||||
| `SMTP_*` / `MAIL_FROM` | Transactional email (Mailpit in dev, or Resend via `RESEND_API_KEY`) |
|
||||
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars, `openssl rand -hex 32`) — encrypts the project vault, integration credentials and webhook secrets at rest. Without it the Vault page and `secret://` job refs are unavailable |
|
||||
| `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app |
|
||||
| `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only |
|
||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app |
|
||||
| `BACKEND_STORAGE_DRIVER` | `local` (default, `BACKEND_STORAGE_ROOT`) or `s3` — S3-compatible object storage |
|
||||
| `S3_ENDPOINT` / `S3_BUCKET` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 backend config (with `S3_REGION`, `S3_PREFIX`, `S3_PATH_STYLE`) |
|
||||
| `S3_PUBLIC_ENDPOINT` | Client-facing S3 host for presigned URLs when `S3_ENDPOINT` is internal-only |
|
||||
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` | JS runtime for Functions — `auto` (default, resolves `bun`/`deno` from PATH) or a binary path; 30 s default timeout |
|
||||
| `VITE_DEMO_MODE` | Enable the client-side demo workspace |
|
||||
|
||||
### Instance administration
|
||||
|
||||
@@ -48,9 +48,14 @@ Supabase, with no hosted tier - your data stays on your hardware.
|
||||
|
||||
- **Organizations & projects** - workspaces with scoped org and project roles.
|
||||
- **Auth** - email/password plus optional GitHub, Google, Discord, and Microsoft OAuth. JWTs verified by the API against JWKS.
|
||||
- **Storage** - S3-style buckets on your local filesystem, public or private.
|
||||
- **Collections** - schema-flexible JSON documents in Postgres JSONB.
|
||||
- **Storage** - S3-style buckets on local disk or any S3-compatible backend (AWS, MinIO, Garage, R2), public or private. With `s3` the API mints presigned upload/download URLs so large objects skip the backend.
|
||||
- **Collections** - schema-flexible JSON documents in Postgres JSONB, with filter/order queries.
|
||||
- **Functions** - project-scoped JS/TS run by bun/deno — on the host (`exec`) or in ephemeral locked-down containers (`docker` driver) — with per-run logs. Trigger them four ways: manual invoke, cron schedules (`function_id` on a job), inbound hooks (`mode: "function"`), or domain events (`event_pattern` like `document.*`).
|
||||
- **Vault** - AES-256-GCM project secrets plus an Argon2id/XChaCha20 local vault in the CLI and desktop app.
|
||||
- **Automation** - scheduled jobs, inbound webhooks, telemetry alert rules, and `secret://` refs resolved at delivery.
|
||||
- **Realtime** - SSE event stream with a typed client SDK (`@primora/client`) — channel subscriptions, presence counts, and client-published `custom.*` events that fan out to webhooks and functions.
|
||||
- **API keys** - `prm_<prefix>_<secret>` credentials, shown once.
|
||||
- **Agent setup** - every project can mint a `write`-scoped key and generate a copyable AI-agent prompt that wires telemetry, database tracking, and MCP/CLI access into any repository.
|
||||
- **Audit log** - every mutating request recorded, with CSV/JSON export.
|
||||
- **Demo mode** - fully client-side seeded workspace via `?demo=true`.
|
||||
|
||||
@@ -91,9 +96,9 @@ and [QUICK_START.md](QUICK_START.md#configuration) for the important ones.
|
||||
|
||||
## Ecosystem
|
||||
|
||||
- **[CLI](apps/cli)** - `primora` for orgs, projects, buckets, objects, keys, and audit from the terminal.
|
||||
- **[CLI](apps/cli)** - `primora` for orgs, projects, buckets, objects, keys, jobs, and audit — plus an encrypted local secrets vault (`vault:*`, `secrets:*`, `inject`), the shared project vault (`secrets:* --remote`, `secret://NAME` refs in job payloads), an agent credential broker (`agent`), and compose-stack operator commands (`stack:*`).
|
||||
- **[MCP server](apps/mcp)** - exposes `primora_*` tools to MCP clients.
|
||||
- **[Desktop](apps/desktop)** - Tauri 2 shell connecting to your deployment, with tray presence.
|
||||
- **[Desktop](apps/desktop)** - Tauri 2 shell connecting to your deployment, with tray presence and a local Secrets Vault window (drives the CLI's vault).
|
||||
- **PWA** - the dashboard is installable on mobile and desktop browsers.
|
||||
|
||||
## Documentation
|
||||
|
||||
+158
-28
@@ -123,8 +123,11 @@ Follow-ups:
|
||||
|
||||
- Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run
|
||||
(host-verified on Linux only).
|
||||
- Desktop bundle signing + auto-updater — unsigned artifacts today; add when
|
||||
distribution matures.
|
||||
- ~~Desktop bundle signing + auto-updater~~ ✅ — `tauri-plugin-updater`
|
||||
wired: signed updater artifacts (`createUpdaterArtifacts`), minisign
|
||||
keypair, `latest.json` manifest assembled on release, in-app
|
||||
"Update & restart" on the connect screen. macOS/Windows OS-level code
|
||||
signing (notarization/Authenticode) still open — needs paid certs.
|
||||
|
||||
## Phase 6 — Production hardening (v0.6.0) ✅
|
||||
|
||||
@@ -185,8 +188,9 @@ release pipeline with images + binaries + desktop bundles.
|
||||
(auto-registers from ingest).
|
||||
- ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres →
|
||||
dragonfly transfer, key templating, TTL).
|
||||
- ~~**External object storage**~~ ✅ — constraint documented in
|
||||
DEPLOYMENT_GUIDE.md / PRODUCTION_READINESS.md; S3 backend deferred.
|
||||
- ~~**External object storage**~~ ✅ — `BACKEND_STORAGE_DRIVER=s3` shipped
|
||||
(Phase 10): any S3-compatible API + presigned URLs; local disk remains
|
||||
the default.
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -205,8 +209,10 @@ release pipeline with images + binaries + desktop bundles.
|
||||
- ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via
|
||||
QEMU + buildx.
|
||||
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle
|
||||
verification, signing + auto-updater.
|
||||
- Optional: SBOM + provenance attestation on release images.
|
||||
verification; updater + minisign signing shipped (OS-level signing
|
||||
remains open).
|
||||
- ~~Optional: SBOM~~ ✅ — CycloneDX SBOMs per published image + source
|
||||
tree, attached to every release (`anchore/sbom-action`).
|
||||
|
||||
### Docs
|
||||
|
||||
@@ -263,32 +269,156 @@ dashboard's live feed off the same stream.
|
||||
`auth-client` now resolves `/auth` against `window.location.origin`,
|
||||
preserving same-origin defaults without breaking split-origin dev.
|
||||
|
||||
## Secrets vault (unreleased)
|
||||
|
||||
Arca-inspired secrets management, clean-room — every surface covered.
|
||||
|
||||
- **Local vault** (`apps/cli`) — `PRMVLT01` format: Argon2id (64 MiB/3/4) →
|
||||
XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes. `vault:*`,
|
||||
`secrets:*`, `inject` (env injection + `primora://` refs), TTL sessions,
|
||||
and the `agent` credential broker (dummy tokens in the child, real secret
|
||||
attached on the wire per grant).
|
||||
- **Project vault** (`core.project_secrets`, migration 00011) — AES-256-GCM
|
||||
via the platform encryptor; metadata-only list, audit-logged reveal;
|
||||
`secret://NAME` refs in job payloads resolve at delivery and fail loudly
|
||||
when unresolved.
|
||||
- **Surfaces** — dashboard Vault page (+ local-vault card under the desktop
|
||||
shell), desktop vault window via Tauri IPC → `primora` binary,
|
||||
`secrets:* --remote` CLI mode, and MCP tools that are metadata/write-only
|
||||
so agents never hold plaintext.
|
||||
|
||||
## Phase 11 — Document queries (unreleased)
|
||||
|
||||
PostgREST-style filtering and ordering on collection documents.
|
||||
|
||||
- `GET /collections/:id/documents?filter=…&order=…` — `filter` is a
|
||||
comma-separated `field.op.value` list (ops `eq neq gt gte lt lte like in
|
||||
is`), `order` a `field.asc|field.desc` list. Dotted paths index into
|
||||
document data (`meta.city.eq.Prague`); `id`/`created_at`/`updated_at` are
|
||||
filterable columns.
|
||||
- Whitelisted field/operator parsing, parameterized values, JSONB
|
||||
extraction — no interpolated SQL. Plain lists keep the sqlc path.
|
||||
- `primora documents list --collection <slug> --filter … --order …` in the
|
||||
CLI; `filter`/`order` params in the generated client.
|
||||
|
||||
## Phase 8 — Realtime client SDK (unreleased)
|
||||
|
||||
Channel subscriptions over the Phase 7 SSE stream, in `@primora/client`.
|
||||
|
||||
- `createRealtimeClient({ endpoint, key, projectId })` — fetch +
|
||||
ReadableStream reader (EventSource can't send `X-API-Key`), auto-reconnect
|
||||
with capped backoff, `onStateChange` lifecycle.
|
||||
- `channel("documents").on("created", cb)` — namespaces `document`, `object`,
|
||||
`issue`, `deploy`, `"*"` for everything; trailing `s` optional.
|
||||
- **Presence** ✅ — server tracks realtime subscribers per project;
|
||||
`presence.update` {online: n} broadcasts on every join/leave (realtime-only,
|
||||
never fans out to webhooks/functions), `GET …/realtime/presence` reads the
|
||||
count, `client.presence()` wraps it.
|
||||
- **Client events** ✅ — `POST /projects/:id/events` publishes `custom.*`
|
||||
types through the standard fan-out (realtime + webhooks + functions);
|
||||
`client.publish("custom.x", data)` and `primora events:send` both reach it.
|
||||
The `custom.*` prefix is enforced so clients can't spoof system events.
|
||||
|
||||
## Notifications — alert rules (unreleased)
|
||||
|
||||
Telemetry-driven alerts delivered through the existing webhook dispatcher.
|
||||
|
||||
- `core.alert_rules` (migration 00012) — `heartbeat_silence` and
|
||||
`error_spike` kinds, per-component or project-wide scope, evaluated by a
|
||||
60 s backend ticker.
|
||||
- Transition-based firing: `alert.fired` once on breach, `alert.resolved`
|
||||
on recovery — no notification storms. Both are webhook event types and
|
||||
realtime events, so any existing subscriber receives them signed.
|
||||
- CRUD via `/projects/:id/alerts`; Integrations page manages rules next to
|
||||
webhooks and shows live `firing` state.
|
||||
|
||||
## Inbound webhooks (unreleased)
|
||||
|
||||
Project-scoped public ingest for external systems.
|
||||
|
||||
- `POST /api/v1/hooks/<token>` — token is the credential; optional
|
||||
`X-Primora-Signature` HMAC-SHA256 verification when a secret is set.
|
||||
- `event` mode republishes the body as `inbound.received` (webhooks +
|
||||
realtime fan out for free); `job` mode enqueues a scheduled-job run with
|
||||
the received body as payload (`triggered_by: "hook"`).
|
||||
- 256 KiB body cap; `last_received_at` tracked; managed from Integrations.
|
||||
|
||||
## HA scheduler (unreleased)
|
||||
|
||||
Multi-replica safety for the job scheduler and alert evaluator.
|
||||
|
||||
- PostgreSQL advisory locks (session-scoped, separate keys per component)
|
||||
on a dedicated pinned pool connection; only the leader scans for due work.
|
||||
- Manual runs and hook-triggered runs work on any replica — the lock gates
|
||||
only the scheduled scan, so leadership loss never blocks on-demand work.
|
||||
- Released on shutdown; failover verified with a live two-replica test —
|
||||
the survivor acquired leadership and fired the due job.
|
||||
|
||||
## Email surface (unreleased)
|
||||
|
||||
Reusable templates + an operational send log.
|
||||
|
||||
- `core.email_log` (migration 00014) — every send recorded: template,
|
||||
recipient, subject, `sent`/`failed` status, error text.
|
||||
- `Mailer` stays transport-only (Resend when configured, SMTP otherwise);
|
||||
templates render subject + plaintext body. First template: `invitation`.
|
||||
- `GET /projects/:id/emails` lists recent sends; Members page shows an
|
||||
"Email log" card under pending invitations.
|
||||
|
||||
## Future phases — candidates
|
||||
|
||||
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
||||
|
||||
- **Phase 8 — Client realtime SDK** — `@primora/client` channel
|
||||
subscriptions (`channel('documents').on('created', …)`), presence,
|
||||
and broadcast over the Phase 7 stream. Supabase-realtime parity is a
|
||||
protocol question, not a transport one.
|
||||
- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers.
|
||||
Deno or Bun isolates in a sidecar; secrets via env, payload via stdin.
|
||||
The job runner already supplies the scheduling half.
|
||||
- **Phase 10 — Storage backends** — S3-compatible object storage behind
|
||||
the existing bucket abstraction (deferred from Phase 6), plus
|
||||
presigned-URL uploads for large files.
|
||||
- **Phase 11 — Generated REST per collection** — PostgREST-style
|
||||
auto-CRUD + filtering over Collections documents; a clean read layer
|
||||
on data that already exists.
|
||||
- **Notifications** — alert rules on telemetry (error-rate thresholds,
|
||||
heartbeat silence) fanning out through the webhook dispatcher.
|
||||
- **Inbound webhooks** — project-scoped ingest endpoints that turn
|
||||
external events into domain events/jobs.
|
||||
- **HA mode** — leader-elected scheduler + distributed locks so two
|
||||
backend replicas don't double-fire jobs; Postgres advisory locks are
|
||||
enough at this scale.
|
||||
- **Email surface** — transactional templates + send log in the
|
||||
dashboard, backed by the auth service's existing SMTP/Resend wiring.
|
||||
- **Phase 9 — Functions** — ✅ shipped: `core.functions` +
|
||||
`core.function_runs`, bun/deno exec runner (stdin payload, env injection,
|
||||
capped output, timeout), invoke API + dashboard page. Triggers shipped:
|
||||
scheduled jobs (`function_id` on a job), inbound hooks (`mode:
|
||||
"function"`), and domain events (`event_pattern`, e.g. `document.*`).
|
||||
Runs record trigger source (`manual`/`schedule`/`hook`/`event`).
|
||||
Isolation shipped: `FUNCTIONS_DRIVER=docker` runs every invoke in an
|
||||
ephemeral locked-down container (no network, read-only fs, non-root).
|
||||
Remaining candidate: Firecracker/gVisor microVMs for hostile
|
||||
multi-tenant workloads — docker socket trust is documented.
|
||||
- **Phase 10 — Storage backends** — ✅ shipped: `BACKEND_STORAGE_DRIVER=s3`
|
||||
+ `S3_*` env vars, stdlib SigV4, verified against a real S3 server.
|
||||
Presigned upload/download URLs shipped: `POST
|
||||
/buckets/:id/object-presigns` mints SigV4 query-signed GET/PUT URLs
|
||||
(`S3_PUBLIC_ENDPOINT` rewrites the client-facing host); local driver
|
||||
returns a clear error — a presigned URL needs a real object API behind it.
|
||||
- **HA mode** — ✅ shipped: PostgreSQL advisory-lock leadership for the
|
||||
scheduler and alert evaluator; failover verified with two replicas.
|
||||
- **Email surface** — ✅ shipped: templated sends + `core.email_log` +
|
||||
dashboard card; more templates land as new flows need them.
|
||||
|
||||
## Release hardening (unreleased)
|
||||
|
||||
- **Desktop auto-updater** — `tauri-plugin-updater`: signed updater
|
||||
artifacts, `latest.json` manifest assembled per release, in-app
|
||||
"Update & restart" on the connect screen. Unsigned artifacts can never
|
||||
install — no signature means no manifest.
|
||||
- **SBOMs** — CycloneDX per GHCR image + source tree, attached to every
|
||||
GitHub Release.
|
||||
- Requires `TAURI_SIGNING_PRIVATE_KEY` repo secret (keypair generated
|
||||
locally; private half never committed).
|
||||
|
||||
## Sandboxed functions (unreleased)
|
||||
|
||||
- `FUNCTIONS_DRIVER=docker` — every invoke runs in an ephemeral sibling
|
||||
container: `--network none`, read-only rootfs, 128 MiB / 0.5 CPU / 64
|
||||
pids, non-root, `--cap-drop ALL`. Source travels as base64 env — no
|
||||
bind mount, so Docker Desktop and remote daemons work. Timeout
|
||||
force-removes the named container.
|
||||
- `exec` remains the default for single-team deployments; the trust-model
|
||||
doc describes both levels.
|
||||
|
||||
## Client events + presence (unreleased)
|
||||
|
||||
- `POST /projects/:id/events` — members/API keys publish `custom.*`
|
||||
events through the standard fan-out (realtime + webhooks + functions);
|
||||
system types can't be spoofed.
|
||||
- Presence — per-project realtime subscriber counts; `presence.update`
|
||||
broadcasts on join/leave (stream-only), `GET …/realtime/presence`
|
||||
reads the count. SDK: `publish()` + `presence()`; CLI: `events:send`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ RUN apt-get update \
|
||||
&& chmod +x /usr/local/bin/dbx-mcp \
|
||||
&& rm /tmp/dbx-mcp.tar.gz
|
||||
|
||||
# Functions runtime — bun static binary from the official image (multi-arch).
|
||||
COPY --from=oven/bun:1.3.14 /usr/local/bin/bun /usr/local/bin/bun
|
||||
|
||||
COPY --from=builder /primora-backend /usr/local/bin/primora-backend
|
||||
COPY --from=builder /app/db ./db
|
||||
COPY --from=builder /app/openapi ./openapi
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
-- Project-level secrets: AES-256-GCM ciphertext produced by the backend's
|
||||
-- Encryptor. Values are write-once — list/read expose metadata only; a
|
||||
-- dedicated reveal endpoint decrypts and is audit-logged. secret://NAME
|
||||
-- references in job payloads resolve at delivery time.
|
||||
CREATE TABLE core.project_secrets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
ciphertext BYTEA NOT NULL,
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, name)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS core.project_secrets;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE core.alert_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('heartbeat_silence','error_spike')),
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
state JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, name)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS core.alert_rules;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE core.inbound_hooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
mode TEXT NOT NULL DEFAULT 'event' CHECK (mode IN ('event','job')),
|
||||
job_id UUID REFERENCES core.scheduled_jobs(id) ON DELETE CASCADE,
|
||||
secret BYTEA NOT NULL DEFAULT ''::bytea,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_received_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, name)
|
||||
);
|
||||
|
||||
ALTER TABLE core.scheduled_job_runs
|
||||
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||
ALTER TABLE core.scheduled_job_runs
|
||||
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||
CHECK (triggered_by IN ('schedule','manual','hook'));
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE core.scheduled_job_runs
|
||||
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||
ALTER TABLE core.scheduled_job_runs
|
||||
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||
CHECK (triggered_by IN ('schedule','manual'));
|
||||
DROP TABLE IF EXISTS core.inbound_hooks;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE core.email_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||
template TEXT NOT NULL,
|
||||
to_email TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('sent','failed')),
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_email_log_project ON core.email_log(project_id, created_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS core.email_log;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE core.functions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
runtime TEXT NOT NULL DEFAULT 'bun' CHECK (runtime IN ('bun','deno')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE core.function_runs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
function_id UUID NOT NULL REFERENCES core.functions(id) ON DELETE CASCADE,
|
||||
trigger TEXT NOT NULL CHECK (trigger IN ('manual','schedule','hook')),
|
||||
status TEXT NOT NULL CHECK (status IN ('success','error','timeout')),
|
||||
exit_code INT,
|
||||
stdout TEXT NOT NULL DEFAULT '',
|
||||
stderr TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX function_runs_function_idx ON core.function_runs(function_id, created_at DESC);
|
||||
|
||||
ALTER TABLE core.scheduled_job_runs
|
||||
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||
ALTER TABLE core.scheduled_job_runs
|
||||
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||
CHECK (triggered_by IN ('schedule','manual','hook','function'));
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE core.scheduled_job_runs
|
||||
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||
ALTER TABLE core.scheduled_job_runs
|
||||
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||
CHECK (triggered_by IN ('schedule','manual','hook'));
|
||||
DROP TABLE IF EXISTS core.function_runs;
|
||||
DROP TABLE IF EXISTS core.functions;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE core.scheduled_jobs
|
||||
ADD COLUMN function_id UUID REFERENCES core.functions(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE core.inbound_hooks
|
||||
ADD COLUMN function_id UUID REFERENCES core.functions(id) ON DELETE CASCADE;
|
||||
ALTER TABLE core.inbound_hooks
|
||||
DROP CONSTRAINT inbound_hooks_mode_check;
|
||||
ALTER TABLE core.inbound_hooks
|
||||
ADD CONSTRAINT inbound_hooks_mode_check
|
||||
CHECK (mode IN ('event','job','function'));
|
||||
|
||||
ALTER TABLE core.functions
|
||||
ADD COLUMN event_pattern TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE core.function_runs
|
||||
DROP CONSTRAINT function_runs_trigger_check;
|
||||
ALTER TABLE core.function_runs
|
||||
ADD CONSTRAINT function_runs_trigger_check
|
||||
CHECK (trigger IN ('manual','schedule','hook','event'));
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE core.function_runs
|
||||
DROP CONSTRAINT function_runs_trigger_check;
|
||||
ALTER TABLE core.function_runs
|
||||
ADD CONSTRAINT function_runs_trigger_check
|
||||
CHECK (trigger IN ('manual','schedule','hook'));
|
||||
ALTER TABLE core.functions DROP COLUMN IF EXISTS event_pattern;
|
||||
ALTER TABLE core.inbound_hooks
|
||||
DROP CONSTRAINT inbound_hooks_mode_check;
|
||||
ALTER TABLE core.inbound_hooks
|
||||
ADD CONSTRAINT inbound_hooks_mode_check CHECK (mode IN ('event','job'));
|
||||
ALTER TABLE core.inbound_hooks DROP COLUMN IF EXISTS function_id;
|
||||
ALTER TABLE core.scheduled_jobs DROP COLUMN IF EXISTS function_id;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- name: CreateAlertRule :one
|
||||
INSERT INTO core.alert_rules (project_id, name, kind, config)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListAlertRules :many
|
||||
SELECT * FROM core.alert_rules
|
||||
WHERE project_id = $1
|
||||
ORDER BY name;
|
||||
|
||||
-- name: ListEnabledAlertRules :many
|
||||
SELECT * FROM core.alert_rules
|
||||
WHERE enabled;
|
||||
|
||||
-- name: GetAlertRule :one
|
||||
SELECT * FROM core.alert_rules
|
||||
WHERE id = $1 AND project_id = $2;
|
||||
|
||||
-- name: UpdateAlertRule :one
|
||||
UPDATE core.alert_rules
|
||||
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
|
||||
WHERE id = $1 AND project_id = $2
|
||||
RETURNING *;
|
||||
|
||||
-- name: SetAlertRuleState :exec
|
||||
UPDATE core.alert_rules
|
||||
SET state = $3, updated_at = NOW()
|
||||
WHERE id = $1 AND project_id = $2;
|
||||
|
||||
-- name: DeleteAlertRule :exec
|
||||
DELETE FROM core.alert_rules
|
||||
WHERE id = $1 AND project_id = $2;
|
||||
|
||||
-- Per-component most recent heartbeat — heartbeat_silence detection.
|
||||
-- name: LastHeartbeats :many
|
||||
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
|
||||
FROM core.components co
|
||||
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
|
||||
WHERE co.project_id = $1
|
||||
GROUP BY co.name;
|
||||
|
||||
-- Per-component error count inside a sliding window — error_spike detection.
|
||||
-- name: RecentErrorCounts :many
|
||||
SELECT co.name AS component_name, count(*) AS errors
|
||||
FROM core.events e
|
||||
JOIN core.components co ON co.id = e.component_id
|
||||
WHERE e.project_id = $1 AND e.type = 'error'
|
||||
AND e.ts > now() - make_interval(secs => $2::bigint)
|
||||
GROUP BY co.name;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- name: InsertEmailLog :one
|
||||
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListEmailLog :many
|
||||
SELECT * FROM core.email_log
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- name: CreateFunction :one
|
||||
INSERT INTO core.functions (project_id, name, code, runtime, event_pattern)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetFunction :one
|
||||
SELECT * FROM core.functions WHERE id = $1;
|
||||
|
||||
-- name: GetFunctionByName :one
|
||||
SELECT * FROM core.functions WHERE project_id = $1 AND name = $2;
|
||||
|
||||
-- name: ListFunctions :many
|
||||
SELECT * FROM core.functions
|
||||
WHERE project_id = $1
|
||||
ORDER BY name;
|
||||
|
||||
-- name: UpdateFunction :one
|
||||
UPDATE core.functions
|
||||
SET code = COALESCE(sqlc.narg('code'), code),
|
||||
enabled = COALESCE(sqlc.narg('enabled'), enabled),
|
||||
event_pattern = COALESCE(sqlc.narg('event_pattern'), event_pattern),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND project_id = $2
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteFunction :exec
|
||||
DELETE FROM core.functions WHERE id = $1 AND project_id = $2;
|
||||
|
||||
-- name: InsertFunctionRun :one
|
||||
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListFunctionRuns :many
|
||||
SELECT * FROM core.function_runs
|
||||
WHERE function_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListFunctionsForEvent :many
|
||||
SELECT * FROM core.functions
|
||||
WHERE project_id = $1
|
||||
AND enabled = TRUE
|
||||
AND event_pattern <> ''
|
||||
AND ($2 LIKE REPLACE(event_pattern, '*', '%') OR event_pattern = '*');
|
||||
@@ -0,0 +1,26 @@
|
||||
-- name: CreateInboundHook :one
|
||||
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, function_id, secret, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListInboundHooks :many
|
||||
SELECT * FROM core.inbound_hooks
|
||||
WHERE project_id = $1
|
||||
ORDER BY name;
|
||||
|
||||
-- name: GetInboundHookByToken :one
|
||||
SELECT * FROM core.inbound_hooks
|
||||
WHERE token = $1;
|
||||
|
||||
-- name: GetInboundHook :one
|
||||
SELECT * FROM core.inbound_hooks
|
||||
WHERE id = $1 AND project_id = $2;
|
||||
|
||||
-- name: TouchInboundHook :exec
|
||||
UPDATE core.inbound_hooks
|
||||
SET last_received_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: DeleteInboundHook :exec
|
||||
DELETE FROM core.inbound_hooks
|
||||
WHERE id = $1 AND project_id = $2;
|
||||
@@ -17,8 +17,9 @@ INSERT INTO core.scheduled_jobs (
|
||||
payload,
|
||||
enabled,
|
||||
next_run_at,
|
||||
created_by_user_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
created_by_user_id,
|
||||
function_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateScheduledJob :one
|
||||
@@ -30,6 +31,7 @@ SET name = $3,
|
||||
next_run_at = $7,
|
||||
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
|
||||
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
|
||||
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND project_id = $2
|
||||
|
||||
@@ -147,3 +147,7 @@ WHERE p.organization_id = $1;
|
||||
DELETE FROM core.organizations
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetOrganizationByID :one
|
||||
SELECT * FROM core.organizations
|
||||
WHERE id = $1;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- name: ListProjectSecrets :many
|
||||
SELECT id, project_id, name, url, notes, created_at, updated_at
|
||||
FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
ORDER BY name ASC;
|
||||
|
||||
-- name: GetProjectSecret :one
|
||||
SELECT * FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
AND name = $2;
|
||||
|
||||
-- name: UpsertProjectSecret :one
|
||||
INSERT INTO core.project_secrets (
|
||||
project_id,
|
||||
name,
|
||||
ciphertext,
|
||||
url,
|
||||
notes
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (project_id, name) DO UPDATE SET
|
||||
ciphertext = EXCLUDED.ciphertext,
|
||||
url = EXCLUDED.url,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteProjectSecret :one
|
||||
DELETE FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
AND name = $2
|
||||
RETURNING id;
|
||||
|
||||
-- name: ListProjectSecretValues :many
|
||||
SELECT name, ciphertext
|
||||
FROM core.project_secrets
|
||||
WHERE project_id = $1;
|
||||
@@ -11,7 +11,10 @@ WHERE id = $1;
|
||||
SELECT * FROM core.webhooks
|
||||
WHERE project_id = $1
|
||||
AND enabled
|
||||
AND (cardinality(events) = 0 OR $2::text = ANY(events));
|
||||
AND (cardinality(events) = 0 OR EXISTS (
|
||||
SELECT 1 FROM unnest(events) AS e
|
||||
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
|
||||
));
|
||||
|
||||
-- name: CreateWebhook :one
|
||||
INSERT INTO core.webhooks (
|
||||
|
||||
@@ -81,9 +81,31 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
||||
}
|
||||
}
|
||||
|
||||
store, err := storage.NewLocalStore(cfg.StorageRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var store storage.Store
|
||||
if cfg.StorageDriver == "s3" {
|
||||
s3Store, err := storage.NewS3Store(storage.S3Config{
|
||||
Endpoint: cfg.S3Endpoint,
|
||||
Region: cfg.S3Region,
|
||||
Bucket: cfg.S3Bucket,
|
||||
AccessKeyID: cfg.S3AccessKeyID,
|
||||
SecretAccessKey: cfg.S3SecretAccessKey,
|
||||
PublicEndpoint: cfg.S3PublicEndpoint,
|
||||
Prefix: cfg.S3Prefix,
|
||||
PathStyle: cfg.S3PathStyle,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("s3 storage: %w", err)
|
||||
}
|
||||
if err := s3Store.EnsureBucket(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store = s3Store
|
||||
} else {
|
||||
localStore, err := storage.NewLocalStore(cfg.StorageRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store = localStore
|
||||
}
|
||||
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
|
||||
repo := repositories.NewCoreRepository(dbPool)
|
||||
@@ -91,7 +113,13 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encryption key: %w", err)
|
||||
}
|
||||
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger)
|
||||
var fnRunner services.FunctionRunner
|
||||
if cfg.FunctionsDriver == "docker" {
|
||||
fnRunner = services.NewDockerRunner(cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
|
||||
} else {
|
||||
fnRunner = services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
|
||||
}
|
||||
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, fnRunner)
|
||||
settings := services.NewSettingsService(repo, encryptor)
|
||||
|
||||
if cfg.Env == "production" {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -35,6 +36,26 @@ type Config struct {
|
||||
SMTPSecure bool
|
||||
|
||||
EncryptionKey string
|
||||
|
||||
// Object storage: "local" (BACKEND_STORAGE_ROOT) or "s3".
|
||||
StorageDriver string
|
||||
S3Endpoint string
|
||||
S3Region string
|
||||
S3Bucket string
|
||||
S3AccessKeyID string
|
||||
S3SecretAccessKey string
|
||||
S3PublicEndpoint string // presigned-URL host when it differs from Endpoint
|
||||
S3Prefix string
|
||||
S3PathStyle bool
|
||||
|
||||
// Functions runtime: driver "exec" (runtime binary on the backend host)
|
||||
// or "docker" (ephemeral locked-down container), binary name/path for
|
||||
// exec ("auto" resolves per-function runtime from PATH), timeout and
|
||||
// output cap.
|
||||
FunctionsDriver string
|
||||
FunctionsRuntime string
|
||||
FunctionsTimeout time.Duration
|
||||
FunctionsMaxBytes int64
|
||||
}
|
||||
|
||||
// devEncryptionKey is the built-in credential key for local development only.
|
||||
@@ -63,6 +84,17 @@ func Load() (Config, error) {
|
||||
SMTPUser: os.Getenv("SMTP_USER"),
|
||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
|
||||
StorageDriver: getenv("BACKEND_STORAGE_DRIVER", "local"),
|
||||
S3Endpoint: os.Getenv("S3_ENDPOINT"),
|
||||
S3Region: getenv("S3_REGION", "us-east-1"),
|
||||
S3Bucket: os.Getenv("S3_BUCKET"),
|
||||
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
|
||||
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
||||
S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"),
|
||||
S3Prefix: os.Getenv("S3_PREFIX"),
|
||||
FunctionsDriver: getenv("FUNCTIONS_DRIVER", "exec"),
|
||||
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
|
||||
FunctionsMaxBytes: 65536,
|
||||
}
|
||||
|
||||
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
||||
@@ -95,6 +127,24 @@ func Load() (Config, error) {
|
||||
}
|
||||
cfg.SMTPSecure = smtpSecure
|
||||
|
||||
s3PathStyle, err := strconv.ParseBool(getenv("S3_PATH_STYLE", "true"))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("parse S3_PATH_STYLE: %w", err)
|
||||
}
|
||||
cfg.S3PathStyle = s3PathStyle
|
||||
|
||||
fnTimeout, err := strconv.Atoi(getenv("FUNCTIONS_TIMEOUT_SECONDS", "30"))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("parse FUNCTIONS_TIMEOUT_SECONDS: %w", err)
|
||||
}
|
||||
cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second
|
||||
|
||||
switch cfg.FunctionsDriver {
|
||||
case "exec", "docker":
|
||||
default:
|
||||
return Config{}, fmt.Errorf("FUNCTIONS_DRIVER %q must be exec or docker", cfg.FunctionsDriver)
|
||||
}
|
||||
|
||||
var missing []string
|
||||
if cfg.DatabaseURL == "" {
|
||||
missing = append(missing, "DATABASE_URL")
|
||||
@@ -102,8 +152,23 @@ func Load() (Config, error) {
|
||||
if cfg.JWTSecret == "" {
|
||||
missing = append(missing, "JWT_SECRET")
|
||||
}
|
||||
if cfg.StorageRoot == "" {
|
||||
missing = append(missing, "BACKEND_STORAGE_ROOT")
|
||||
switch cfg.StorageDriver {
|
||||
case "local":
|
||||
if cfg.StorageRoot == "" {
|
||||
missing = append(missing, "BACKEND_STORAGE_ROOT")
|
||||
}
|
||||
case "s3":
|
||||
if cfg.S3Bucket == "" {
|
||||
missing = append(missing, "S3_BUCKET")
|
||||
}
|
||||
if cfg.S3AccessKeyID == "" {
|
||||
missing = append(missing, "S3_ACCESS_KEY_ID")
|
||||
}
|
||||
if cfg.S3SecretAccessKey == "" {
|
||||
missing = append(missing, "S3_SECRET_ACCESS_KEY")
|
||||
}
|
||||
default:
|
||||
return Config{}, fmt.Errorf("unknown BACKEND_STORAGE_DRIVER %q (want local or s3)", cfg.StorageDriver)
|
||||
}
|
||||
if cfg.AuthInternalBaseURL == "" {
|
||||
missing = append(missing, "AUTH_INTERNAL_BASE_URL")
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: alert_rules.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createAlertRule = `-- name: CreateAlertRule :one
|
||||
INSERT INTO core.alert_rules (project_id, name, kind, config)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateAlertRuleParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Config []byte `json:"config"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) {
|
||||
row := q.db.QueryRow(ctx, createAlertRule,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.Kind,
|
||||
arg.Config,
|
||||
)
|
||||
var i CoreAlertRule
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Config,
|
||||
&i.Enabled,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteAlertRule = `-- name: DeleteAlertRule :exec
|
||||
DELETE FROM core.alert_rules
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type DeleteAlertRuleParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteAlertRule, arg.ID, arg.ProjectID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAlertRule = `-- name: GetAlertRule :one
|
||||
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type GetAlertRuleParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) {
|
||||
row := q.db.QueryRow(ctx, getAlertRule, arg.ID, arg.ProjectID)
|
||||
var i CoreAlertRule
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Config,
|
||||
&i.Enabled,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const lastHeartbeats = `-- name: LastHeartbeats :many
|
||||
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
|
||||
FROM core.components co
|
||||
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
|
||||
WHERE co.project_id = $1
|
||||
GROUP BY co.name
|
||||
`
|
||||
|
||||
type LastHeartbeatsRow struct {
|
||||
ComponentName string `json:"component_name"`
|
||||
LastBeat pgtype.Timestamptz `json:"last_beat"`
|
||||
}
|
||||
|
||||
// Per-component most recent heartbeat — heartbeat_silence detection.
|
||||
func (q *Queries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) {
|
||||
rows, err := q.db.Query(ctx, lastHeartbeats, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []LastHeartbeatsRow
|
||||
for rows.Next() {
|
||||
var i LastHeartbeatsRow
|
||||
if err := rows.Scan(&i.ComponentName, &i.LastBeat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAlertRules = `-- name: ListAlertRules :many
|
||||
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||
WHERE project_id = $1
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) {
|
||||
rows, err := q.db.Query(ctx, listAlertRules, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreAlertRule
|
||||
for rows.Next() {
|
||||
var i CoreAlertRule
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Config,
|
||||
&i.Enabled,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listEnabledAlertRules = `-- name: ListEnabledAlertRules :many
|
||||
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||
WHERE enabled
|
||||
`
|
||||
|
||||
func (q *Queries) ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) {
|
||||
rows, err := q.db.Query(ctx, listEnabledAlertRules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreAlertRule
|
||||
for rows.Next() {
|
||||
var i CoreAlertRule
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Config,
|
||||
&i.Enabled,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const recentErrorCounts = `-- name: RecentErrorCounts :many
|
||||
SELECT co.name AS component_name, count(*) AS errors
|
||||
FROM core.events e
|
||||
JOIN core.components co ON co.id = e.component_id
|
||||
WHERE e.project_id = $1 AND e.type = 'error'
|
||||
AND e.ts > now() - make_interval(secs => $2::bigint)
|
||||
GROUP BY co.name
|
||||
`
|
||||
|
||||
type RecentErrorCountsParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Column2 int64 `json:"column_2"`
|
||||
}
|
||||
|
||||
type RecentErrorCountsRow struct {
|
||||
ComponentName string `json:"component_name"`
|
||||
Errors int64 `json:"errors"`
|
||||
}
|
||||
|
||||
// Per-component error count inside a sliding window — error_spike detection.
|
||||
func (q *Queries) RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) {
|
||||
rows, err := q.db.Query(ctx, recentErrorCounts, arg.ProjectID, arg.Column2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []RecentErrorCountsRow
|
||||
for rows.Next() {
|
||||
var i RecentErrorCountsRow
|
||||
if err := rows.Scan(&i.ComponentName, &i.Errors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setAlertRuleState = `-- name: SetAlertRuleState :exec
|
||||
UPDATE core.alert_rules
|
||||
SET state = $3, updated_at = NOW()
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type SetAlertRuleStateParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
State []byte `json:"state"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error {
|
||||
_, err := q.db.Exec(ctx, setAlertRuleState, arg.ID, arg.ProjectID, arg.State)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateAlertRule = `-- name: UpdateAlertRule :one
|
||||
UPDATE core.alert_rules
|
||||
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
|
||||
WHERE id = $1 AND project_id = $2
|
||||
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateAlertRuleParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Config []byte `json:"config"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) {
|
||||
row := q.db.QueryRow(ctx, updateAlertRule,
|
||||
arg.ID,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.Config,
|
||||
arg.Enabled,
|
||||
)
|
||||
var i CoreAlertRule
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Kind,
|
||||
&i.Config,
|
||||
&i.Enabled,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: email_log.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const insertEmailLog = `-- name: InsertEmailLog :one
|
||||
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, project_id, template, to_email, subject, status, error, created_at
|
||||
`
|
||||
|
||||
type InsertEmailLogParams struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Template string `json:"template"`
|
||||
ToEmail string `json:"to_email"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) {
|
||||
row := q.db.QueryRow(ctx, insertEmailLog,
|
||||
arg.ProjectID,
|
||||
arg.Template,
|
||||
arg.ToEmail,
|
||||
arg.Subject,
|
||||
arg.Status,
|
||||
arg.Error,
|
||||
)
|
||||
var i CoreEmailLog
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Template,
|
||||
&i.ToEmail,
|
||||
&i.Subject,
|
||||
&i.Status,
|
||||
&i.Error,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listEmailLog = `-- name: ListEmailLog :many
|
||||
SELECT id, project_id, template, to_email, subject, status, error, created_at FROM core.email_log
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListEmailLogParams struct {
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) {
|
||||
rows, err := q.db.Query(ctx, listEmailLog, arg.ProjectID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreEmailLog
|
||||
for rows.Next() {
|
||||
var i CoreEmailLog
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Template,
|
||||
&i.ToEmail,
|
||||
&i.Subject,
|
||||
&i.Status,
|
||||
&i.Error,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: functions.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createFunction = `-- name: CreateFunction :one
|
||||
INSERT INTO core.functions (project_id, name, code, runtime, event_pattern)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
|
||||
`
|
||||
|
||||
type CreateFunctionParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Runtime string `json:"runtime"`
|
||||
EventPattern string `json:"event_pattern"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error) {
|
||||
row := q.db.QueryRow(ctx, createFunction,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.Code,
|
||||
arg.Runtime,
|
||||
arg.EventPattern,
|
||||
)
|
||||
var i CoreFunction
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.Runtime,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EventPattern,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteFunction = `-- name: DeleteFunction :exec
|
||||
DELETE FROM core.functions WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type DeleteFunctionParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteFunction, arg.ID, arg.ProjectID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getFunction = `-- name: GetFunction :one
|
||||
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error) {
|
||||
row := q.db.QueryRow(ctx, getFunction, id)
|
||||
var i CoreFunction
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.Runtime,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EventPattern,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getFunctionByName = `-- name: GetFunctionByName :one
|
||||
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE project_id = $1 AND name = $2
|
||||
`
|
||||
|
||||
type GetFunctionByNameParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error) {
|
||||
row := q.db.QueryRow(ctx, getFunctionByName, arg.ProjectID, arg.Name)
|
||||
var i CoreFunction
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.Runtime,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EventPattern,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertFunctionRun = `-- name: InsertFunctionRun :one
|
||||
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at
|
||||
`
|
||||
|
||||
type InsertFunctionRunParams struct {
|
||||
FunctionID uuid.UUID `json:"function_id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
DurationMs int32 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error) {
|
||||
row := q.db.QueryRow(ctx, insertFunctionRun,
|
||||
arg.FunctionID,
|
||||
arg.Trigger,
|
||||
arg.Status,
|
||||
arg.ExitCode,
|
||||
arg.Stdout,
|
||||
arg.Stderr,
|
||||
arg.DurationMs,
|
||||
)
|
||||
var i CoreFunctionRun
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.FunctionID,
|
||||
&i.Trigger,
|
||||
&i.Status,
|
||||
&i.ExitCode,
|
||||
&i.Stdout,
|
||||
&i.Stderr,
|
||||
&i.DurationMs,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listFunctionRuns = `-- name: ListFunctionRuns :many
|
||||
SELECT id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at FROM core.function_runs
|
||||
WHERE function_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListFunctionRunsParams struct {
|
||||
FunctionID uuid.UUID `json:"function_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error) {
|
||||
rows, err := q.db.Query(ctx, listFunctionRuns, arg.FunctionID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreFunctionRun
|
||||
for rows.Next() {
|
||||
var i CoreFunctionRun
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.FunctionID,
|
||||
&i.Trigger,
|
||||
&i.Status,
|
||||
&i.ExitCode,
|
||||
&i.Stdout,
|
||||
&i.Stderr,
|
||||
&i.DurationMs,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listFunctions = `-- name: ListFunctions :many
|
||||
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
|
||||
WHERE project_id = $1
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error) {
|
||||
rows, err := q.db.Query(ctx, listFunctions, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreFunction
|
||||
for rows.Next() {
|
||||
var i CoreFunction
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.Runtime,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EventPattern,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listFunctionsForEvent = `-- name: ListFunctionsForEvent :many
|
||||
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
|
||||
WHERE project_id = $1
|
||||
AND enabled = TRUE
|
||||
AND event_pattern <> ''
|
||||
AND ($2 LIKE REPLACE(event_pattern, '*', '%') OR event_pattern = '*')
|
||||
`
|
||||
|
||||
type ListFunctionsForEventParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
EventPattern string `json:"event_pattern"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error) {
|
||||
rows, err := q.db.Query(ctx, listFunctionsForEvent, arg.ProjectID, arg.EventPattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreFunction
|
||||
for rows.Next() {
|
||||
var i CoreFunction
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.Runtime,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EventPattern,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateFunction = `-- name: UpdateFunction :one
|
||||
UPDATE core.functions
|
||||
SET code = COALESCE($3, code),
|
||||
enabled = COALESCE($4, enabled),
|
||||
event_pattern = COALESCE($5, event_pattern),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND project_id = $2
|
||||
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
|
||||
`
|
||||
|
||||
type UpdateFunctionParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Code *string `json:"code"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
EventPattern *string `json:"event_pattern"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error) {
|
||||
row := q.db.QueryRow(ctx, updateFunction,
|
||||
arg.ID,
|
||||
arg.ProjectID,
|
||||
arg.Code,
|
||||
arg.Enabled,
|
||||
arg.EventPattern,
|
||||
)
|
||||
var i CoreFunction
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.Runtime,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.EventPattern,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: inbound_hooks.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createInboundHook = `-- name: CreateInboundHook :one
|
||||
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, function_id, secret, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id
|
||||
`
|
||||
|
||||
type CreateInboundHookParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
FunctionID pgtype.UUID `json:"function_id"`
|
||||
Secret []byte `json:"secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error) {
|
||||
row := q.db.QueryRow(ctx, createInboundHook,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.Token,
|
||||
arg.Mode,
|
||||
arg.JobID,
|
||||
arg.FunctionID,
|
||||
arg.Secret,
|
||||
arg.Enabled,
|
||||
)
|
||||
var i CoreInboundHook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Mode,
|
||||
&i.JobID,
|
||||
&i.Secret,
|
||||
&i.Enabled,
|
||||
&i.LastReceivedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteInboundHook = `-- name: DeleteInboundHook :exec
|
||||
DELETE FROM core.inbound_hooks
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type DeleteInboundHookParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteInboundHook, arg.ID, arg.ProjectID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getInboundHook = `-- name: GetInboundHook :one
|
||||
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type GetInboundHookParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error) {
|
||||
row := q.db.QueryRow(ctx, getInboundHook, arg.ID, arg.ProjectID)
|
||||
var i CoreInboundHook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Mode,
|
||||
&i.JobID,
|
||||
&i.Secret,
|
||||
&i.Enabled,
|
||||
&i.LastReceivedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getInboundHookByToken = `-- name: GetInboundHookByToken :one
|
||||
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||
WHERE token = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error) {
|
||||
row := q.db.QueryRow(ctx, getInboundHookByToken, token)
|
||||
var i CoreInboundHook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Mode,
|
||||
&i.JobID,
|
||||
&i.Secret,
|
||||
&i.Enabled,
|
||||
&i.LastReceivedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listInboundHooks = `-- name: ListInboundHooks :many
|
||||
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||
WHERE project_id = $1
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error) {
|
||||
rows, err := q.db.Query(ctx, listInboundHooks, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreInboundHook
|
||||
for rows.Next() {
|
||||
var i CoreInboundHook
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Mode,
|
||||
&i.JobID,
|
||||
&i.Secret,
|
||||
&i.Enabled,
|
||||
&i.LastReceivedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const touchInboundHook = `-- name: TouchInboundHook :exec
|
||||
UPDATE core.inbound_hooks
|
||||
SET last_received_at = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) TouchInboundHook(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, touchInboundHook, id)
|
||||
return err
|
||||
}
|
||||
@@ -22,9 +22,10 @@ INSERT INTO core.scheduled_jobs (
|
||||
payload,
|
||||
enabled,
|
||||
next_run_at,
|
||||
created_by_user_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
|
||||
created_by_user_id,
|
||||
function_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
|
||||
`
|
||||
|
||||
type CreateScheduledJobParams struct {
|
||||
@@ -37,6 +38,7 @@ type CreateScheduledJobParams struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
|
||||
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
||||
FunctionID pgtype.UUID `json:"function_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) {
|
||||
@@ -50,6 +52,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob
|
||||
arg.Enabled,
|
||||
arg.NextRunAt,
|
||||
arg.CreatedByUserID,
|
||||
arg.FunctionID,
|
||||
)
|
||||
var i CoreScheduledJob
|
||||
err := row.Scan(
|
||||
@@ -67,6 +70,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob
|
||||
&i.CreatedByUserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -133,7 +137,7 @@ func (q *Queries) FinishScheduledJobRun(ctx context.Context, arg FinishScheduled
|
||||
}
|
||||
|
||||
const getScheduledJobByID = `-- name: GetScheduledJobByID :one
|
||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
|
||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
@@ -155,6 +159,7 @@ func (q *Queries) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreSc
|
||||
&i.CreatedByUserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -188,7 +193,7 @@ func (q *Queries) InsertScheduledJobRun(ctx context.Context, arg InsertScheduled
|
||||
}
|
||||
|
||||
const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many
|
||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
|
||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
|
||||
WHERE enabled
|
||||
AND next_run_at IS NOT NULL
|
||||
AND next_run_at <= $1
|
||||
@@ -218,6 +223,7 @@ func (q *Queries) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Tim
|
||||
&i.CreatedByUserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -272,7 +278,7 @@ func (q *Queries) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJob
|
||||
}
|
||||
|
||||
const listScheduledJobs = `-- name: ListScheduledJobs :many
|
||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
|
||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
@@ -301,6 +307,7 @@ func (q *Queries) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([
|
||||
&i.CreatedByUserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -319,7 +326,7 @@ SET last_run_at = $2,
|
||||
next_run_at = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
|
||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
|
||||
`
|
||||
|
||||
type MarkScheduledJobRanParams struct {
|
||||
@@ -352,6 +359,7 @@ func (q *Queries) MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobR
|
||||
&i.CreatedByUserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -386,24 +394,27 @@ SET name = $3,
|
||||
next_run_at = $7,
|
||||
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
|
||||
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
|
||||
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND project_id = $2
|
||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
|
||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
|
||||
`
|
||||
|
||||
type UpdateScheduledJobParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Schedule string `json:"schedule"`
|
||||
Url string `json:"url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
|
||||
Column8 bool `json:"column_8"`
|
||||
Secret []byte `json:"secret"`
|
||||
Column10 bool `json:"column_10"`
|
||||
Payload []byte `json:"payload"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Schedule string `json:"schedule"`
|
||||
Url string `json:"url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
|
||||
Column8 bool `json:"column_8"`
|
||||
Secret []byte `json:"secret"`
|
||||
Column10 bool `json:"column_10"`
|
||||
Payload []byte `json:"payload"`
|
||||
Column12 bool `json:"column_12"`
|
||||
FunctionID pgtype.UUID `json:"function_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) {
|
||||
@@ -419,6 +430,8 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob
|
||||
arg.Secret,
|
||||
arg.Column10,
|
||||
arg.Payload,
|
||||
arg.Column12,
|
||||
arg.FunctionID,
|
||||
)
|
||||
var i CoreScheduledJob
|
||||
err := row.Scan(
|
||||
@@ -436,6 +449,7 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob
|
||||
&i.CreatedByUserID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FunctionID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -140,6 +140,18 @@ func (ns NullCoreProjectRole) Value() (driver.Value, error) {
|
||||
return string(ns.CoreProjectRole), nil
|
||||
}
|
||||
|
||||
type CoreAlertRule struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Config []byte `json:"config"`
|
||||
Enabled bool `json:"enabled"`
|
||||
State []byte `json:"state"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreApiKey struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
@@ -231,6 +243,17 @@ type CoreDocument struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreEmailLog struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID pgtype.UUID `json:"project_id"`
|
||||
Template string `json:"template"`
|
||||
ToEmail string `json:"to_email"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CoreEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
@@ -243,6 +266,45 @@ type CoreEvent struct {
|
||||
Ts pgtype.Timestamptz `json:"ts"`
|
||||
}
|
||||
|
||||
type CoreFunction struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Runtime string `json:"runtime"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
EventPattern string `json:"event_pattern"`
|
||||
}
|
||||
|
||||
type CoreFunctionRun struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
FunctionID uuid.UUID `json:"function_id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
ExitCode *int32 `json:"exit_code"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
DurationMs int32 `json:"duration_ms"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CoreInboundHook struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
Secret []byte `json:"secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastReceivedAt pgtype.Timestamptz `json:"last_received_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
FunctionID pgtype.UUID `json:"function_id"`
|
||||
}
|
||||
|
||||
type CoreIntegration struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
@@ -307,6 +369,17 @@ type CoreProjectMember struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CoreProjectSecret struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Ciphertext []byte `json:"ciphertext"`
|
||||
Url string `json:"url"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreScheduledJob struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
@@ -322,6 +395,7 @@ type CoreScheduledJob struct {
|
||||
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
FunctionID pgtype.UUID `json:"function_id"`
|
||||
}
|
||||
|
||||
type CoreScheduledJobRun struct {
|
||||
|
||||
@@ -241,6 +241,23 @@ func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOrganizationByID = `-- name: GetOrganizationByID :one
|
||||
SELECT id, slug, name, created_at FROM core.organizations
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) {
|
||||
row := q.db.QueryRow(ctx, getOrganizationByID, id)
|
||||
var i CoreOrganization
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Slug,
|
||||
&i.Name,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
|
||||
SELECT
|
||||
om.id, om.organization_id, om.user_id, om.role, om.created_at,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: project_secrets.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteProjectSecret = `-- name: DeleteProjectSecret :one
|
||||
DELETE FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
AND name = $2
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type DeleteProjectSecretParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error) {
|
||||
row := q.db.QueryRow(ctx, deleteProjectSecret, arg.ProjectID, arg.Name)
|
||||
var id uuid.UUID
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const getProjectSecret = `-- name: GetProjectSecret :one
|
||||
SELECT id, project_id, name, ciphertext, url, notes, created_at, updated_at FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
AND name = $2
|
||||
`
|
||||
|
||||
type GetProjectSecretParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error) {
|
||||
row := q.db.QueryRow(ctx, getProjectSecret, arg.ProjectID, arg.Name)
|
||||
var i CoreProjectSecret
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Ciphertext,
|
||||
&i.Url,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listProjectSecretValues = `-- name: ListProjectSecretValues :many
|
||||
SELECT name, ciphertext
|
||||
FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
`
|
||||
|
||||
type ListProjectSecretValuesRow struct {
|
||||
Name string `json:"name"`
|
||||
Ciphertext []byte `json:"ciphertext"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listProjectSecretValues, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListProjectSecretValuesRow
|
||||
for rows.Next() {
|
||||
var i ListProjectSecretValuesRow
|
||||
if err := rows.Scan(&i.Name, &i.Ciphertext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listProjectSecrets = `-- name: ListProjectSecrets :many
|
||||
SELECT id, project_id, name, url, notes, created_at, updated_at
|
||||
FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
ORDER BY name ASC
|
||||
`
|
||||
|
||||
type ListProjectSecretsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listProjectSecrets, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListProjectSecretsRow
|
||||
for rows.Next() {
|
||||
var i ListProjectSecretsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Url,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertProjectSecret = `-- name: UpsertProjectSecret :one
|
||||
INSERT INTO core.project_secrets (
|
||||
project_id,
|
||||
name,
|
||||
ciphertext,
|
||||
url,
|
||||
notes
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (project_id, name) DO UPDATE SET
|
||||
ciphertext = EXCLUDED.ciphertext,
|
||||
url = EXCLUDED.url,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = NOW()
|
||||
RETURNING id, project_id, name, ciphertext, url, notes, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertProjectSecretParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Ciphertext []byte `json:"ciphertext"`
|
||||
Url string `json:"url"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error) {
|
||||
row := q.db.QueryRow(ctx, upsertProjectSecret,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.Ciphertext,
|
||||
arg.Url,
|
||||
arg.Notes,
|
||||
)
|
||||
var i CoreProjectSecret
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Ciphertext,
|
||||
&i.Url,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -23,28 +23,35 @@ type Querier interface {
|
||||
CountOrganizations(ctx context.Context) (int64, error)
|
||||
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
|
||||
CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error)
|
||||
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
|
||||
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
|
||||
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
|
||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
||||
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
||||
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
||||
CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error)
|
||||
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
|
||||
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
||||
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
||||
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
|
||||
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
|
||||
CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error)
|
||||
CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error)
|
||||
DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error
|
||||
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
|
||||
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
||||
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
||||
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
||||
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
||||
DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error
|
||||
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
|
||||
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
||||
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
||||
DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error)
|
||||
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
|
||||
DeleteSetting(ctx context.Context, key string) error
|
||||
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error)
|
||||
@@ -53,6 +60,7 @@ type Querier interface {
|
||||
FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
|
||||
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
|
||||
GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error)
|
||||
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
|
||||
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
|
||||
@@ -61,13 +69,19 @@ type Querier interface {
|
||||
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
||||
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
||||
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
||||
GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error)
|
||||
GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error)
|
||||
GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error)
|
||||
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error)
|
||||
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
|
||||
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
||||
GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
|
||||
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
||||
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
|
||||
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
|
||||
GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error)
|
||||
GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error)
|
||||
GetSetting(ctx context.Context, key string) (GetSettingRow, error)
|
||||
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
|
||||
@@ -75,10 +89,15 @@ type Querier interface {
|
||||
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
|
||||
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
|
||||
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
|
||||
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error)
|
||||
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
||||
InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error)
|
||||
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
|
||||
// Per-component most recent heartbeat — heartbeat_silence detection.
|
||||
LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error)
|
||||
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
|
||||
ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error)
|
||||
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
||||
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
||||
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
|
||||
@@ -88,14 +107,22 @@ type Querier interface {
|
||||
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
|
||||
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
||||
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
|
||||
ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error)
|
||||
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
||||
ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error)
|
||||
ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error)
|
||||
ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error)
|
||||
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
|
||||
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
||||
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
||||
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
||||
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
|
||||
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
|
||||
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
|
||||
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error)
|
||||
ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error)
|
||||
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
|
||||
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
|
||||
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error)
|
||||
@@ -110,10 +137,13 @@ type Querier interface {
|
||||
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
|
||||
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
||||
PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error
|
||||
// Per-component error count inside a sliding window — error_spike detection.
|
||||
RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error)
|
||||
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
|
||||
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
||||
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
|
||||
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
|
||||
SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error
|
||||
SweepExpiredAuditLogs(ctx context.Context) (int64, error)
|
||||
// Per-project retention sweeps. A retention of 0 days disables the sweep for
|
||||
// that stream. Pending webhook deliveries are never swept — they may still be
|
||||
@@ -124,9 +154,12 @@ type Querier interface {
|
||||
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
|
||||
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
|
||||
TouchAPIKey(ctx context.Context, id uuid.UUID) error
|
||||
TouchInboundHook(ctx context.Context, id uuid.UUID) error
|
||||
UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error)
|
||||
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
||||
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
||||
UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error)
|
||||
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
|
||||
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
|
||||
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
||||
@@ -135,6 +168,7 @@ type Querier interface {
|
||||
UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
|
||||
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
|
||||
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
|
||||
UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error)
|
||||
UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error)
|
||||
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
|
||||
}
|
||||
|
||||
@@ -291,7 +291,10 @@ const listWebhooksForEvent = `-- name: ListWebhooksForEvent :many
|
||||
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
|
||||
WHERE project_id = $1
|
||||
AND enabled
|
||||
AND (cardinality(events) = 0 OR $2::text = ANY(events))
|
||||
AND (cardinality(events) = 0 OR EXISTS (
|
||||
SELECT 1 FROM unnest(events) AS e
|
||||
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
|
||||
))
|
||||
`
|
||||
|
||||
type ListWebhooksForEventParams struct {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||
)
|
||||
|
||||
func (h *HTTPHandler) alertError(c *gin.Context, err error) {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
apperrors.Abort(c, http.StatusNotFound, "not_found", "alert rule not found")
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "invalid") || strings.Contains(msg, "must be") || strings.Contains(msg, "already exists") || strings.Contains(msg, "too long") {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||
return
|
||||
}
|
||||
h.handleError(c, err)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listAlertRules(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.ListAlertRules(c.Request.Context(), actor, projectID)
|
||||
if err != nil {
|
||||
h.alertError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) createAlertRule(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body services.UpsertAlertRuleInput
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.CreateAlertRule(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.alertError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, result)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) updateAlertRule(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ruleID, err := uuid.Parse(c.Param("ruleID"))
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id")
|
||||
return
|
||||
}
|
||||
var body services.UpsertAlertRuleInput
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.UpdateAlertRule(c.Request.Context(), actor, projectID, ruleID, body, middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.alertError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteAlertRule(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ruleID, err := uuid.Parse(c.Param("ruleID"))
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id")
|
||||
return
|
||||
}
|
||||
if err := h.Platform.DeleteAlertRule(c.Request.Context(), actor, projectID, ruleID, middleware.RequestIDFromContext(c)); err != nil {
|
||||
h.alertError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// publishEvent accepts a client-authored domain event (custom.* namespace)
|
||||
// and fans it out through the standard publishEvent path — realtime
|
||||
// subscribers, matching webhooks, and event_pattern functions all fire.
|
||||
func (h *HTTPHandler) publishEvent(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Type string `json:"type" validate:"required"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
if err := h.Platform.PublishProjectEvent(c.Request.Context(), actor, projectID, body.Type, body.Data); err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"published": body.Type})
|
||||
}
|
||||
|
||||
// realtimePresence returns the count of clients currently subscribed to the
|
||||
// project's realtime stream.
|
||||
func (h *HTTPHandler) realtimePresence(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := h.Platform.Presence(c.Request.Context(), actor, projectID)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"online": n})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||
)
|
||||
|
||||
func (h *HTTPHandler) listFunctions(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.Platform.ListFunctions(c.Request.Context(), actor, projectID)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) createFunction(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body services.CreateFunctionInput
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
fn, err := h.Platform.CreateFunction(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, fn)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) getFunction(c *gin.Context) {
|
||||
actor, _, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
functionID, ok := parseUUIDParam(c, "functionID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fn, code, err := h.Platform.GetFunctionCode(c.Request.Context(), actor, functionID)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"function": fn, "code": code})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) updateFunction(c *gin.Context) {
|
||||
actor, _, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
functionID, ok := parseUUIDParam(c, "functionID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body services.UpdateFunctionInput
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
fn, err := h.Platform.UpdateFunction(c.Request.Context(), actor, functionID, body, middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, fn)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteFunction(c *gin.Context) {
|
||||
actor, _, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
functionID, ok := parseUUIDParam(c, "functionID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Platform.DeleteFunction(c.Request.Context(), actor, functionID, middleware.RequestIDFromContext(c)); err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) invokeFunction(c *gin.Context) {
|
||||
actor, _, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
functionID, ok := parseUUIDParam(c, "functionID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
// An empty body is a valid invoke — defaults to {}.
|
||||
if c.Request.Body != nil && c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_json", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
run, err := h.Platform.InvokeFunction(c.Request.Context(), actor, functionID, body.Payload, middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if run.Status != "success" {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
c.JSON(status, run)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listFunctionRuns(c *gin.Context) {
|
||||
actor, _, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
functionID, ok := parseUUIDParam(c, "functionID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.ParseInt(c.DefaultQuery("limit", "50"), 10, 64)
|
||||
runs, err := h.Platform.ListFunctionRuns(c.Request.Context(), actor, functionID, limit)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": runs})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -11,7 +12,9 @@ import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/observability"
|
||||
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||
@@ -34,6 +37,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
api.GET("/me", h.me)
|
||||
api.GET("/context", h.actorContext)
|
||||
api.GET("/instance/public", h.instancePublic)
|
||||
api.GET("/instance/settings", h.listInstanceSettings)
|
||||
api.PUT("/instance/settings/:key", h.updateInstanceSetting)
|
||||
@@ -78,7 +82,20 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
||||
api.OPTIONS("/ingest", h.ingestOptions)
|
||||
api.POST("/ingest", h.ingest)
|
||||
api.POST("/hooks/:token", h.receiveInboundHook)
|
||||
api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks)
|
||||
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
|
||||
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
|
||||
api.GET("/projects/:projectID/emails", h.listEmailLog)
|
||||
api.GET("/projects/:projectID/functions", h.listFunctions)
|
||||
api.POST("/projects/:projectID/functions", h.createFunction)
|
||||
api.GET("/projects/:projectID/functions/:functionID", h.getFunction)
|
||||
api.PUT("/projects/:projectID/functions/:functionID", h.updateFunction)
|
||||
api.DELETE("/projects/:projectID/functions/:functionID", h.deleteFunction)
|
||||
api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction)
|
||||
api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns)
|
||||
api.GET("/projects/:projectID/events", h.listEvents)
|
||||
api.POST("/projects/:projectID/events", h.publishEvent)
|
||||
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||
api.GET("/projects/:projectID/components", h.listComponents)
|
||||
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
|
||||
@@ -116,10 +133,21 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob)
|
||||
api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns)
|
||||
api.POST("/projects/:projectID/jobs/:jobID/run", h.runJob)
|
||||
api.GET("/projects/:projectID/secrets", h.listProjectSecrets)
|
||||
api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret)
|
||||
api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret)
|
||||
api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret)
|
||||
|
||||
api.GET("/projects/:projectID/alerts", h.listAlertRules)
|
||||
api.POST("/projects/:projectID/alerts", h.createAlertRule)
|
||||
api.PUT("/projects/:projectID/alerts/:ruleID", h.updateAlertRule)
|
||||
api.DELETE("/projects/:projectID/alerts/:ruleID", h.deleteAlertRule)
|
||||
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
|
||||
api.GET("/projects/:projectID/realtime/presence", h.realtimePresence)
|
||||
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
||||
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
||||
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
|
||||
api.POST("/buckets/:bucketID/object-presigns", h.presignObject)
|
||||
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
|
||||
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
|
||||
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
|
||||
@@ -156,6 +184,19 @@ func (h *HTTPHandler) me(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) actorContext(c *gin.Context) {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.ActorContext(c.Request.Context(), actor)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) bootstrap(c *gin.Context) {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
@@ -869,6 +910,43 @@ func (h *HTTPHandler) updateObject(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type presignObjectRequest struct {
|
||||
Key string `json:"key" validate:"required"`
|
||||
Op string `json:"op" validate:"omitempty,oneof=download upload"`
|
||||
TTLSeconds int `json:"ttl_seconds" validate:"omitempty,min=60,max=3600"`
|
||||
}
|
||||
|
||||
// presignObject returns a short-lived URL clients call against storage
|
||||
// directly — keeps large transfers off the backend. S3 driver only.
|
||||
func (h *HTTPHandler) presignObject(c *gin.Context) {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
bucketID, ok := parseUUIDParam(c, "bucketID")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body presignObjectRequest
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
out, err := h.Platform.PresignObject(c.Request.Context(), actor, bucketID, strings.TrimSpace(body.Key), body.Op, body.TTLSeconds)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrPresignUnsupported) {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "presign_unsupported", err.Error())
|
||||
return
|
||||
}
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteObject(c *gin.Context) {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
@@ -1053,12 +1131,19 @@ func (h *HTTPHandler) listDocuments(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
actor, _ := middleware.ActorFromContext(c)
|
||||
result, err := h.Platform.ListDocuments(c.Request.Context(), actor, collectionID, limit, offset)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
var result []db.CoreDocument
|
||||
var total int64
|
||||
var err error
|
||||
// PostgREST-style filter/order switches to the dynamic-query path; the
|
||||
// plain list keeps using the sqlc queries it always has.
|
||||
if filter := c.Query("filter"); filter != "" || c.Query("order") != "" {
|
||||
result, total, err = h.Platform.SearchDocuments(c.Request.Context(), actor, collectionID, filter, c.Query("order"), limit, offset)
|
||||
} else {
|
||||
result, err = h.Platform.ListDocuments(c.Request.Context(), actor, collectionID, limit, offset)
|
||||
if err == nil {
|
||||
total, err = h.Platform.CountDocuments(c.Request.Context(), actor, collectionID)
|
||||
}
|
||||
}
|
||||
total, err := h.Platform.CountDocuments(c.Request.Context(), actor, collectionID)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
@@ -1195,17 +1280,58 @@ func (h *HTTPHandler) bindAndValidate(c *gin.Context, target any) bool {
|
||||
return false
|
||||
}
|
||||
if err := h.Validate.Struct(target); err != nil {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", err.Error())
|
||||
var valErrs validator.ValidationErrors
|
||||
if errors.As(err, &valErrs) && len(valErrs) > 0 {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", formatFieldError(valErrs[0]))
|
||||
} else {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", "invalid request body")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func formatFieldError(f validator.FieldError) string {
|
||||
if f.Tag() == "required" {
|
||||
return fmt.Sprintf("%s is required", strings.ToLower(f.Field()))
|
||||
}
|
||||
return fmt.Sprintf("%s failed %s validation", strings.ToLower(f.Field()), f.Tag())
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) handleError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, io.EOF):
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
apperrors.Abort(c, http.StatusNotFound, "not_found", "resource not found")
|
||||
default:
|
||||
var inputErr *services.InputError
|
||||
if errors.As(err, &inputErr) {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", inputErr.Error())
|
||||
return
|
||||
}
|
||||
var valErrs validator.ValidationErrors
|
||||
if errors.As(err, &valErrs) && len(valErrs) > 0 {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", formatFieldError(valErrs[0]))
|
||||
return
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
// Structured codes — never leak raw SQLSTATE text or constraint names.
|
||||
switch pgErr.Code {
|
||||
case "23505":
|
||||
apperrors.Abort(c, http.StatusConflict, "conflict", "resource already exists")
|
||||
case "23503":
|
||||
apperrors.Abort(c, http.StatusConflict, "conflict", "referenced resource does not exist")
|
||||
case "22001", "54000":
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "value too large")
|
||||
case "22021":
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "input is not valid UTF-8")
|
||||
default:
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "request_failed", "database error")
|
||||
}
|
||||
return
|
||||
}
|
||||
status := http.StatusInternalServerError
|
||||
message := strings.ToLower(err.Error())
|
||||
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
|
||||
|
||||
@@ -4,9 +4,14 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||
)
|
||||
|
||||
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
@@ -104,4 +109,46 @@ func TestHandleErrorStatusMapping(t *testing.T) {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("maps missing rows to not found", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("")
|
||||
handler.handleError(ctx, pgx.ErrNoRows)
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("maps InputError to bad request with message", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("")
|
||||
handler.handleError(ctx, services.NewInputError("webhook url must be https"))
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "webhook url must be https") {
|
||||
t.Fatalf("expected reason in body, got %s", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("maps unique violation to conflict without leaking", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("")
|
||||
handler.handleError(ctx, &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint \"projects_slug_key\""})
|
||||
if recorder.Code != http.StatusConflict {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "projects_slug_key") || strings.Contains(recorder.Body.String(), "23505") {
|
||||
t.Fatalf("raw constraint leaked: %s", recorder.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("maps unknown pg error to internal error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("")
|
||||
handler.handleError(ctx, &pgconn.PgError{Code: "XX000", Message: "internal"})
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||
)
|
||||
|
||||
const maxInboundBody = 256 * 1024 // external POSTs capped at 256 KiB
|
||||
|
||||
func (h *HTTPHandler) inboundError(c *gin.Context, err error) {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
apperrors.Abort(c, http.StatusNotFound, "not_found", "hook not found")
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "invalid signature"):
|
||||
apperrors.Abort(c, http.StatusUnauthorized, "unauthorized", msg)
|
||||
case strings.Contains(msg, "invalid") || strings.Contains(msg, "already exists") || strings.Contains(msg, "disabled") || strings.Contains(msg, "requires") || strings.Contains(msg, "not found in this project"):
|
||||
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||
default:
|
||||
h.handleError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listInboundHooks(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.ListInboundHooks(c.Request.Context(), actor, projectID)
|
||||
if err != nil {
|
||||
h.inboundError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) createInboundHook(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body services.CreateInboundHookInput
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.CreateInboundHook(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.inboundError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, result)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteInboundHook(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hookID, err := uuid.Parse(c.Param("hookID"))
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid hook id")
|
||||
return
|
||||
}
|
||||
if err := h.Platform.DeleteInboundHook(c.Request.Context(), actor, projectID, hookID, middleware.RequestIDFromContext(c)); err != nil {
|
||||
h.inboundError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listEmailLog(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var limit int64
|
||||
if raw := c.Query("limit"); raw != "" {
|
||||
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
result, err := h.Platform.ListEmailLog(c.Request.Context(), actor, projectID, limit)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||
}
|
||||
|
||||
// receiveInboundHook is the public ingest endpoint — the URL token is the
|
||||
// credential, so no actor is required.
|
||||
func (h *HTTPHandler) receiveInboundHook(c *gin.Context) {
|
||||
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxInboundBody))
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusRequestEntityTooLarge, "too_large", "body exceeds 256 KiB")
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.ReceiveInboundHook(c.Request.Context(), c.Param("token"), c.GetHeader("X-Primora-Signature"), body)
|
||||
if err != nil {
|
||||
h.inboundError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, result)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||
)
|
||||
|
||||
func (h *HTTPHandler) secretError(c *gin.Context, err error) {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
apperrors.Abort(c, http.StatusNotFound, "not_found", "secret not found")
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "invalid secret name") {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||
return
|
||||
}
|
||||
if strings.Contains(msg, "encryption is not configured") {
|
||||
apperrors.Abort(c, http.StatusServiceUnavailable, "unavailable", msg)
|
||||
return
|
||||
}
|
||||
h.handleError(c, err)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listProjectSecrets(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.ListProjectSecrets(c.Request.Context(), actor, projectID)
|
||||
if err != nil {
|
||||
h.secretError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) setProjectSecret(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body services.SetProjectSecretInput
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
body.Name = c.Param("name")
|
||||
result, err := h.Platform.SetProjectSecret(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.secretError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteProjectSecret(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Platform.DeleteProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c)); err != nil {
|
||||
h.secretError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) revealProjectSecret(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.RevealProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.secretError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
@@ -27,6 +27,12 @@ func (r *CoreRepository) Queries() *db.Queries {
|
||||
return r.queries
|
||||
}
|
||||
|
||||
// Pool exposes the pgx pool for dynamic queries sqlc can't express
|
||||
// (PostgREST-style document filtering builds its WHERE clause at runtime).
|
||||
func (r *CoreRepository) Pool() *pgxpool.Pool {
|
||||
return r.pool
|
||||
}
|
||||
|
||||
func (r *CoreRepository) WithTx(ctx context.Context, fn func(*db.Queries) error) error {
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||
)
|
||||
|
||||
// Alert rules detect conditions in telemetry (component heartbeat silence,
|
||||
// error-rate spikes) and fire `alert.fired` / `alert.resolved` domain events.
|
||||
// Delivery reuses the webhook dispatcher + realtime stream — the evaluator is
|
||||
// the only new machinery. Rules fire on state transitions only, so a flaky
|
||||
// component produces one fired + one resolved, not a storm.
|
||||
|
||||
const (
|
||||
AlertEventFired = "alert.fired"
|
||||
AlertEventResolved = "alert.resolved"
|
||||
|
||||
AlertKindHeartbeatSilence = "heartbeat_silence"
|
||||
AlertKindErrorSpike = "error_spike"
|
||||
)
|
||||
|
||||
// AlertRuleConfig is the JSONB config per rule kind.
|
||||
//
|
||||
// heartbeat_silence: component beats less often than ThresholdMinutes.
|
||||
// error_spike: component logs more than ThresholdCount errors in
|
||||
// WindowMinutes.
|
||||
// Component empty = evaluate every component in the project.
|
||||
type AlertRuleConfig struct {
|
||||
Component string `json:"component,omitempty"`
|
||||
ThresholdMinutes int `json:"threshold_minutes,omitempty"`
|
||||
ThresholdCount int64 `json:"threshold_count,omitempty"`
|
||||
WindowMinutes int `json:"window_minutes,omitempty"`
|
||||
}
|
||||
|
||||
type AlertRuleSummary struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Config AlertRuleConfig `json:"config"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Firing []string `json:"firing"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UpsertAlertRuleInput struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Kind string `json:"kind" validate:"required"`
|
||||
Config AlertRuleConfig `json:"config"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *PlatformService) validateAlertRule(input UpsertAlertRuleInput) (AlertRuleConfig, error) {
|
||||
if !projectSecretNameRe.MatchString(input.Name) {
|
||||
return AlertRuleConfig{}, fmt.Errorf("invalid alert rule name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name)
|
||||
}
|
||||
cfg := input.Config
|
||||
switch input.Kind {
|
||||
case AlertKindHeartbeatSilence:
|
||||
if cfg.ThresholdMinutes < 1 || cfg.ThresholdMinutes > 10080 {
|
||||
return cfg, errors.New("threshold_minutes must be 1–10080")
|
||||
}
|
||||
case AlertKindErrorSpike:
|
||||
if cfg.ThresholdCount < 1 {
|
||||
return cfg, errors.New("threshold_count must be ≥ 1")
|
||||
}
|
||||
if cfg.WindowMinutes == 0 {
|
||||
cfg.WindowMinutes = 15
|
||||
}
|
||||
if cfg.WindowMinutes < 1 || cfg.WindowMinutes > 1440 {
|
||||
return cfg, errors.New("window_minutes must be 1–1440")
|
||||
}
|
||||
default:
|
||||
return cfg, fmt.Errorf("invalid kind %q — %s or %s", input.Kind, AlertKindHeartbeatSilence, AlertKindErrorSpike)
|
||||
}
|
||||
if cfg.Component != "" && len(cfg.Component) > 128 {
|
||||
return cfg, errors.New("component name too long")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func alertRuleSummary(row db.CoreAlertRule) AlertRuleSummary {
|
||||
var cfg AlertRuleConfig
|
||||
_ = json.Unmarshal(row.Config, &cfg)
|
||||
firing := parseFiring(row.State)
|
||||
return AlertRuleSummary{
|
||||
ID: row.ID.String(),
|
||||
ProjectID: row.ProjectID.String(),
|
||||
Name: row.Name,
|
||||
Kind: row.Kind,
|
||||
Config: cfg,
|
||||
Enabled: row.Enabled,
|
||||
Firing: firing.list(),
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListAlertRules(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]AlertRuleSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.repo.Queries().ListAlertRules(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]AlertRuleSummary, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = alertRuleSummary(r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) CreateAlertRule(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input UpsertAlertRuleInput, requestID string) (AlertRuleSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return AlertRuleSummary{}, err
|
||||
}
|
||||
cfg, err := s.validateAlertRule(input)
|
||||
if err != nil {
|
||||
return AlertRuleSummary{}, err
|
||||
}
|
||||
rawCfg, _ := json.Marshal(cfg)
|
||||
row, err := s.repo.Queries().CreateAlertRule(ctx, db.CreateAlertRuleParams{
|
||||
ProjectID: projectID,
|
||||
Name: input.Name,
|
||||
Kind: input.Kind,
|
||||
Config: rawCfg,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "alert_rules_project_id_name_key") {
|
||||
return AlertRuleSummary{}, fmt.Errorf("an alert rule named %s already exists", input.Name)
|
||||
}
|
||||
return AlertRuleSummary{}, err
|
||||
}
|
||||
s.auditAlert(ctx, actor, projectID, requestID, "alert.created", row.ID, input.Name)
|
||||
return alertRuleSummary(row), nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) UpdateAlertRule(ctx context.Context, actor *models.Actor, projectID, ruleID uuid.UUID, input UpsertAlertRuleInput, requestID string) (AlertRuleSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return AlertRuleSummary{}, err
|
||||
}
|
||||
if _, err := s.repo.Queries().GetAlertRule(ctx, db.GetAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil {
|
||||
return AlertRuleSummary{}, err // pgx.ErrNoRows → 404 at the handler
|
||||
}
|
||||
cfg, err := s.validateAlertRule(input)
|
||||
if err != nil {
|
||||
return AlertRuleSummary{}, err
|
||||
}
|
||||
enabled := true
|
||||
if input.Enabled != nil {
|
||||
enabled = *input.Enabled
|
||||
}
|
||||
rawCfg, _ := json.Marshal(cfg)
|
||||
row, err := s.repo.Queries().UpdateAlertRule(ctx, db.UpdateAlertRuleParams{
|
||||
ID: ruleID, ProjectID: projectID, Name: input.Name, Config: rawCfg, Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
return AlertRuleSummary{}, err
|
||||
}
|
||||
s.auditAlert(ctx, actor, projectID, requestID, "alert.updated", row.ID, input.Name)
|
||||
return alertRuleSummary(row), nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) DeleteAlertRule(ctx context.Context, actor *models.Actor, projectID, ruleID uuid.UUID, requestID string) error {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.repo.Queries().GetAlertRule(ctx, db.GetAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.Queries().DeleteAlertRule(ctx, db.DeleteAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.auditAlert(ctx, actor, projectID, requestID, "alert.deleted", ruleID, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) auditAlert(ctx context.Context, actor *models.Actor, projectID uuid.UUID, requestID, action string, ruleID uuid.UUID, name string) {
|
||||
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, action, "alert_rule", ruleID.String(), map[string]any{"name": name}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// evaluator — one ticker per backend, transition-based firing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type firingSet map[string]bool
|
||||
|
||||
func parseFiring(raw []byte) firingSet {
|
||||
var state struct {
|
||||
Firing map[string]bool `json:"firing"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &state); err != nil || state.Firing == nil {
|
||||
return firingSet{}
|
||||
}
|
||||
return state.Firing
|
||||
}
|
||||
|
||||
func (f firingSet) list() []string {
|
||||
out := make([]string, 0, len(f))
|
||||
for name, on := range f {
|
||||
if on {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// alertQueries is the slice of db.Queries the evaluator needs — narrow so
|
||||
// tests can stub it without a database.
|
||||
type alertQueries interface {
|
||||
ListEnabledAlertRules(ctx context.Context) ([]db.CoreAlertRule, error)
|
||||
LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]db.LastHeartbeatsRow, error)
|
||||
RecentErrorCounts(ctx context.Context, arg db.RecentErrorCountsParams) ([]db.RecentErrorCountsRow, error)
|
||||
SetAlertRuleState(ctx context.Context, arg db.SetAlertRuleStateParams) error
|
||||
}
|
||||
|
||||
type alertEvaluator struct {
|
||||
q alertQueries
|
||||
publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
|
||||
logger *slog.Logger
|
||||
tick time.Duration
|
||||
quit chan struct{}
|
||||
wg sync.WaitGroup
|
||||
started sync.Once
|
||||
leader *leaderState
|
||||
}
|
||||
|
||||
func newAlertEvaluator(q alertQueries, publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), logger *slog.Logger, pool *pgxpool.Pool) *alertEvaluator {
|
||||
return &alertEvaluator{q: q, publish: publish, logger: logger, tick: time.Minute, quit: make(chan struct{}), leader: newLeaderState(pool, leaderKeyAlerts)}
|
||||
}
|
||||
|
||||
func (e *alertEvaluator) Start(ctx context.Context) {
|
||||
e.started.Do(func() {
|
||||
e.wg.Add(1)
|
||||
go func() {
|
||||
defer e.wg.Done()
|
||||
t := time.NewTicker(e.tick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
if e.leader.hold(ctx) {
|
||||
e.scan(ctx)
|
||||
}
|
||||
case <-e.quit:
|
||||
e.leader.stop(context.Background())
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func (e *alertEvaluator) Stop() {
|
||||
close(e.quit)
|
||||
e.wg.Wait()
|
||||
}
|
||||
|
||||
func (e *alertEvaluator) scan(ctx context.Context) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
rules, err := e.q.ListEnabledAlertRules(ctx)
|
||||
if err != nil {
|
||||
e.logger.Warn("alert scan: list rules", "error", err)
|
||||
return
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if err := e.evaluate(ctx, rule); err != nil {
|
||||
e.logger.Warn("alert scan: evaluate", "rule", rule.Name, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *alertEvaluator) evaluate(ctx context.Context, rule db.CoreAlertRule) error {
|
||||
var cfg AlertRuleConfig
|
||||
if err := json.Unmarshal(rule.Config, &cfg); err != nil {
|
||||
return nil // bad config can't be fixed by re-scanning; skip quietly
|
||||
}
|
||||
now := time.Now()
|
||||
var breached map[string]float64 // component → measured value
|
||||
var metric, unit string
|
||||
|
||||
switch rule.Kind {
|
||||
case AlertKindHeartbeatSilence:
|
||||
rows, err := e.q.LastHeartbeats(ctx, rule.ProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
breached = map[string]float64{}
|
||||
for _, r := range rows {
|
||||
silent := now.Sub(r.LastBeat.Time).Minutes()
|
||||
if silent >= float64(cfg.ThresholdMinutes) {
|
||||
breached[r.ComponentName] = silent
|
||||
}
|
||||
}
|
||||
metric, unit = "silence", "minutes"
|
||||
|
||||
case AlertKindErrorSpike:
|
||||
rows, err := e.q.RecentErrorCounts(ctx, db.RecentErrorCountsParams{
|
||||
ProjectID: rule.ProjectID,
|
||||
Column2: int64(cfg.WindowMinutes) * 60,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
breached = map[string]float64{}
|
||||
for _, r := range rows {
|
||||
if r.Errors >= cfg.ThresholdCount {
|
||||
breached[r.ComponentName] = float64(r.Errors)
|
||||
}
|
||||
}
|
||||
metric, unit = "errors", "count"
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
firing := parseFiring(rule.State)
|
||||
changed := false
|
||||
// newly breached components → fired
|
||||
for comp, value := range breached {
|
||||
if cfg.Component != "" && comp != cfg.Component {
|
||||
continue
|
||||
}
|
||||
if firing[comp] {
|
||||
continue
|
||||
}
|
||||
firing[comp] = true
|
||||
changed = true
|
||||
e.publish(ctx, rule.ProjectID, AlertEventFired, map[string]any{
|
||||
"rule_id": rule.ID.String(), "rule": rule.Name, "kind": rule.Kind,
|
||||
"component": comp, "metric": metric, "value": value, "unit": unit,
|
||||
})
|
||||
}
|
||||
// recovered components → resolved
|
||||
for comp, on := range firing {
|
||||
_, stillBreached := breached[comp]
|
||||
if !on || stillBreached {
|
||||
continue
|
||||
}
|
||||
firing[comp] = false
|
||||
changed = true
|
||||
e.publish(ctx, rule.ProjectID, AlertEventResolved, map[string]any{
|
||||
"rule_id": rule.ID.String(), "rule": rule.Name, "kind": rule.Kind,
|
||||
"component": comp,
|
||||
})
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"firing": firing})
|
||||
return e.q.SetAlertRuleState(ctx, db.SetAlertRuleStateParams{ID: rule.ID, ProjectID: rule.ProjectID, State: raw})
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
)
|
||||
|
||||
type stubAlertQueries struct {
|
||||
rules []db.CoreAlertRule
|
||||
beats []db.LastHeartbeatsRow
|
||||
errors []db.RecentErrorCountsRow
|
||||
stateSaved map[uuid.UUID][]byte
|
||||
}
|
||||
|
||||
func (s *stubAlertQueries) ListEnabledAlertRules(ctx context.Context) ([]db.CoreAlertRule, error) {
|
||||
return s.rules, nil
|
||||
}
|
||||
func (s *stubAlertQueries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]db.LastHeartbeatsRow, error) {
|
||||
return s.beats, nil
|
||||
}
|
||||
func (s *stubAlertQueries) RecentErrorCounts(ctx context.Context, arg db.RecentErrorCountsParams) ([]db.RecentErrorCountsRow, error) {
|
||||
return s.errors, nil
|
||||
}
|
||||
func (s *stubAlertQueries) SetAlertRuleState(ctx context.Context, arg db.SetAlertRuleStateParams) error {
|
||||
if s.stateSaved == nil {
|
||||
s.stateSaved = map[uuid.UUID][]byte{}
|
||||
}
|
||||
s.stateSaved[arg.ID] = arg.State
|
||||
// persist back so subsequent evaluations see the transition
|
||||
for i, r := range s.rules {
|
||||
if r.ID == arg.ID {
|
||||
s.rules[i].State = arg.State
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rule(t *testing.T, kind string, cfg AlertRuleConfig) db.CoreAlertRule {
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db.CoreAlertRule{ID: uuid.New(), ProjectID: uuid.New(), Name: "r1", Kind: kind, Config: raw, State: []byte(`{"firing":{}}`)}
|
||||
}
|
||||
|
||||
func collect() (func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), *[]string) {
|
||||
events := &[]string{}
|
||||
return func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
|
||||
*events = append(*events, eventType+":"+data["component"].(string))
|
||||
}, events
|
||||
}
|
||||
|
||||
func TestAlertSilenceFiresOnceThenResolves(t *testing.T) {
|
||||
r := rule(t, AlertKindHeartbeatSilence, AlertRuleConfig{ThresholdMinutes: 5})
|
||||
stub := &stubAlertQueries{
|
||||
rules: []db.CoreAlertRule{r},
|
||||
beats: []db.LastHeartbeatsRow{{
|
||||
ComponentName: "web",
|
||||
LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-10 * time.Minute), Valid: true},
|
||||
}},
|
||||
}
|
||||
publish, events := collect()
|
||||
e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()}
|
||||
|
||||
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*events) != 1 || (*events)[0] != "alert.fired:web" {
|
||||
t.Fatalf("want one alert.fired:web, got %v", *events)
|
||||
}
|
||||
|
||||
// second evaluation — still silent, must not refire
|
||||
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*events) != 1 {
|
||||
t.Fatalf("refired: %v", *events)
|
||||
}
|
||||
|
||||
// heartbeat resumes → resolved
|
||||
stub.beats[0].LastBeat = pgtype.Timestamptz{Time: time.Now(), Valid: true}
|
||||
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*events) != 2 || (*events)[1] != "alert.resolved:web" {
|
||||
t.Fatalf("want alert.resolved:web, got %v", *events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertErrorSpikeThreshold(t *testing.T) {
|
||||
r := rule(t, AlertKindErrorSpike, AlertRuleConfig{ThresholdCount: 3, WindowMinutes: 15})
|
||||
stub := &stubAlertQueries{
|
||||
rules: []db.CoreAlertRule{r},
|
||||
errors: []db.RecentErrorCountsRow{{ComponentName: "api", Errors: 2}},
|
||||
}
|
||||
publish, events := collect()
|
||||
e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()}
|
||||
|
||||
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*events) != 0 {
|
||||
t.Fatalf("under threshold fired: %v", *events)
|
||||
}
|
||||
|
||||
stub.errors[0].Errors = 5
|
||||
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*events) != 1 || (*events)[0] != "alert.fired:api" {
|
||||
t.Fatalf("want alert.fired:api, got %v", *events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertComponentFilter(t *testing.T) {
|
||||
r := rule(t, AlertKindHeartbeatSilence, AlertRuleConfig{Component: "web", ThresholdMinutes: 5})
|
||||
stub := &stubAlertQueries{
|
||||
rules: []db.CoreAlertRule{r},
|
||||
beats: []db.LastHeartbeatsRow{
|
||||
{ComponentName: "web", LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}},
|
||||
{ComponentName: "api", LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}},
|
||||
},
|
||||
}
|
||||
publish, events := collect()
|
||||
e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()}
|
||||
|
||||
if err := e.evaluate(context.Background(), stub.rules[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*events) != 1 || (*events)[0] != "alert.fired:web" {
|
||||
t.Fatalf("filtered rule must fire only for web, got %v", *events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAlertRule(t *testing.T) {
|
||||
s := &PlatformService{}
|
||||
if _, err := s.validateAlertRule(UpsertAlertRuleInput{Name: "ok", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 5}}); err != nil {
|
||||
t.Fatalf("valid silence rule rejected: %v", err)
|
||||
}
|
||||
cfg, err := s.validateAlertRule(UpsertAlertRuleInput{Name: "ok", Kind: AlertKindErrorSpike, Config: AlertRuleConfig{ThresholdCount: 3}})
|
||||
if err != nil || cfg.WindowMinutes != 15 {
|
||||
t.Fatalf("window default: cfg=%+v err=%v", cfg, err)
|
||||
}
|
||||
for _, bad := range []UpsertAlertRuleInput{
|
||||
{Name: "bad name!", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 5}},
|
||||
{Name: "x", Kind: "bogus", Config: AlertRuleConfig{}},
|
||||
{Name: "x", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 0}},
|
||||
{Name: "x", Kind: AlertKindErrorSpike, Config: AlertRuleConfig{ThresholdCount: 0}},
|
||||
} {
|
||||
if _, err := s.validateAlertRule(bad); err == nil {
|
||||
t.Fatalf("accepted invalid rule %+v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||
)
|
||||
|
||||
// PostgREST-lite filtering over document JSONB:
|
||||
// ?filter=status.eq.active,age.gt.18,role.in.admin|editor
|
||||
// ?order=name.asc,created_at.desc
|
||||
// Values are always parameterized; only the whitelisted operators and
|
||||
// field-path syntax below ever reach the SQL string.
|
||||
|
||||
var docFieldRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
// documentColumns are real table columns — everything else resolves into data.
|
||||
var documentColumns = map[string]string{
|
||||
"id": "id",
|
||||
"created_at": "created_at",
|
||||
"updated_at": "updated_at",
|
||||
}
|
||||
|
||||
// docFieldExpr maps a (possibly dotted) field path to a SQL text expression.
|
||||
// data #>> '{a,b}' extracts nested values as text — the correct comparison
|
||||
// domain for loosely-typed document fields.
|
||||
func docFieldExpr(field string) (string, error) {
|
||||
if col, ok := documentColumns[field]; ok {
|
||||
return col, nil
|
||||
}
|
||||
parts := strings.Split(field, ".")
|
||||
for _, p := range parts {
|
||||
if !docFieldRe.MatchString(p) {
|
||||
return "", fmt.Errorf("invalid field name %q — use [A-Za-z_][A-Za-z0-9_]* segments", field)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("data #>> '{%s}'", strings.Join(parts, ",")), nil
|
||||
}
|
||||
|
||||
// numericExpr wraps the field for ordered comparisons — only used when the
|
||||
// filter value itself parses as a float, so the cast can never fail.
|
||||
func numericExpr(expr string) string {
|
||||
return fmt.Sprintf("(%s)::numeric", expr)
|
||||
}
|
||||
|
||||
// buildDocumentFilters turns "field.op.value" terms into parameterized SQL.
|
||||
// Returns the WHERE fragments and the argument list (already $N-numbered via
|
||||
// startArg). A fragment is empty for `is` filters since they carry no value.
|
||||
func buildDocumentFilters(filterParam string, startArg int) ([]string, []any, error) {
|
||||
var conds []string
|
||||
var args []any
|
||||
argN := startArg
|
||||
next := func(v any) string {
|
||||
args = append(args, v)
|
||||
argN++
|
||||
return fmt.Sprintf("$%d", argN-1)
|
||||
}
|
||||
|
||||
var docOps = []string{"eq", "neq", "gt", "gte", "lt", "lte", "like", "in", "is"}
|
||||
|
||||
for _, term := range strings.Split(filterParam, ",") {
|
||||
term = strings.TrimSpace(term)
|
||||
if term == "" {
|
||||
continue
|
||||
}
|
||||
// Field paths are themselves dotted, so the operator is located by
|
||||
// scanning for the first ".<known-op>." — the value may contain dots.
|
||||
var field, op, value string
|
||||
best := -1
|
||||
for _, candidate := range docOps {
|
||||
needle := "." + candidate + "."
|
||||
if i := strings.Index(term, needle); i > 0 && (best < 0 || i < best) {
|
||||
best = i
|
||||
field, op, value = term[:i], candidate, term[i+len(needle):]
|
||||
}
|
||||
}
|
||||
if field == "" {
|
||||
return nil, nil, fmt.Errorf("invalid filter %q — expected field.op.value (ops: %s)", term, strings.Join(docOps, ", "))
|
||||
}
|
||||
expr, err := docFieldExpr(field)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
_, isFloat := strconv.ParseFloat(value, 64)
|
||||
|
||||
switch op {
|
||||
case "eq", "neq":
|
||||
sym := "="
|
||||
if op == "neq" {
|
||||
sym = "<>"
|
||||
}
|
||||
conds = append(conds, fmt.Sprintf("%s %s %s", expr, sym, next(value)))
|
||||
case "gt", "gte", "lt", "lte":
|
||||
if isFloat != nil {
|
||||
return nil, nil, fmt.Errorf("filter %q: %s requires a numeric value", term, op)
|
||||
}
|
||||
sym := map[string]string{"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op]
|
||||
f, _ := strconv.ParseFloat(value, 64)
|
||||
conds = append(conds, fmt.Sprintf("%s %s %s", numericExpr(expr), sym, next(f)))
|
||||
case "like":
|
||||
conds = append(conds, fmt.Sprintf("%s ILIKE %s", expr, next(value)))
|
||||
case "in":
|
||||
items := strings.Split(value, "|")
|
||||
for i, item := range items {
|
||||
items[i] = strings.TrimSpace(item)
|
||||
}
|
||||
conds = append(conds, fmt.Sprintf("%s = ANY(%s)", expr, next(items)))
|
||||
case "is":
|
||||
switch value {
|
||||
case "null":
|
||||
conds = append(conds, fmt.Sprintf("%s IS NULL", expr))
|
||||
case "true", "false":
|
||||
conds = append(conds, fmt.Sprintf("%s = %s", expr, next(value)))
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("filter %q: is accepts null|true|false", term)
|
||||
}
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unknown operator %q in filter %q — supported: eq, neq, gt, gte, lt, lte, like, in, is", op, term)
|
||||
}
|
||||
}
|
||||
return conds, args, nil
|
||||
}
|
||||
|
||||
// buildDocumentOrder turns "name.asc,created_at.desc" into an ORDER BY list.
|
||||
func buildDocumentOrder(orderParam string) (string, error) {
|
||||
if orderParam == "" {
|
||||
return "created_at DESC", nil
|
||||
}
|
||||
var parts []string
|
||||
for _, term := range strings.Split(orderParam, ",") {
|
||||
term = strings.TrimSpace(term)
|
||||
if term == "" {
|
||||
continue
|
||||
}
|
||||
// Direction is a suffix, not a split — field paths are dotted too.
|
||||
field, dir := term, "asc"
|
||||
if strings.HasSuffix(term, ".desc") {
|
||||
field, dir = strings.TrimSuffix(term, ".desc"), "desc"
|
||||
} else if strings.HasSuffix(term, ".asc") {
|
||||
field = strings.TrimSuffix(term, ".asc")
|
||||
}
|
||||
expr, err := docFieldExpr(field)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts = append(parts, expr+" "+strings.ToUpper(dir))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "created_at DESC", nil
|
||||
}
|
||||
return strings.Join(parts, ", "), nil
|
||||
}
|
||||
|
||||
// SearchDocuments lists documents with PostgREST-style filter/order params.
|
||||
// Returns the page plus the un-paginated match count for "total".
|
||||
func (s *PlatformService) SearchDocuments(
|
||||
ctx context.Context,
|
||||
actor *models.Actor,
|
||||
collectionID uuid.UUID,
|
||||
filterParam, orderParam string,
|
||||
limit, offset int32,
|
||||
) ([]db.CoreDocument, int64, error) {
|
||||
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
args := []any{collectionID}
|
||||
conds, filterArgs, err := buildDocumentFilters(filterParam, len(args)+1)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
args = append(args, filterArgs...)
|
||||
|
||||
where := "collection_id = $1"
|
||||
if len(conds) > 0 {
|
||||
where += " AND " + strings.Join(conds, " AND ")
|
||||
}
|
||||
order, err := buildDocumentOrder(orderParam)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.repo.Pool().QueryRow(ctx,
|
||||
fmt.Sprintf("SELECT COUNT(*) FROM core.documents WHERE %s", where), args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
args = append(args, limit, offset)
|
||||
rows, err := s.repo.Pool().Query(ctx, fmt.Sprintf(
|
||||
"SELECT * FROM core.documents WHERE %s ORDER BY %s LIMIT $%d OFFSET $%d",
|
||||
where, order, len(args)-1, len(args)), args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []db.CoreDocument
|
||||
for rows.Next() {
|
||||
var d db.CoreDocument
|
||||
if err := rows.Scan(&d.ID, &d.CollectionID, &d.Data, &d.CreatedByUserID, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildDocumentFilters(t *testing.T) {
|
||||
t.Run("eq on a nested path uses #>>", func(t *testing.T) {
|
||||
conds, args, err := buildDocumentFilters("meta.city.eq.Prague", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
want := "data #>> '{meta,city}' = $2"
|
||||
if conds[0] != want || !reflect.DeepEqual(args, []any{"Prague"}) {
|
||||
t.Fatalf("got %v %v", conds, args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("numeric ops cast to ::numeric", func(t *testing.T) {
|
||||
conds, args, err := buildDocumentFilters("age.gt.18,age.lt.65", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !strings.Contains(conds[0], "::numeric > $2") || !strings.Contains(conds[1], "::numeric < $3") {
|
||||
t.Fatalf("got %v", conds)
|
||||
}
|
||||
if args[0] != 18.0 || args[1] != 65.0 {
|
||||
t.Fatalf("args: %v", args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("numeric op rejects non-numeric value", func(t *testing.T) {
|
||||
_, _, err := buildDocumentFilters("age.gt.abc", 2)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("like becomes ILIKE", func(t *testing.T) {
|
||||
conds, _, err := buildDocumentFilters("name.like.%_smith%", 2)
|
||||
if err != nil || conds[0] != "data #>> '{name}' ILIKE $2" {
|
||||
t.Fatalf("got %v %v", conds, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("in becomes ANY with a text array", func(t *testing.T) {
|
||||
conds, args, err := buildDocumentFilters("role.in.admin|editor", 2)
|
||||
if err != nil || !strings.Contains(conds[0], "= ANY($2)") {
|
||||
t.Fatalf("got %v", conds)
|
||||
}
|
||||
if !reflect.DeepEqual(args[0], []string{"admin", "editor"}) {
|
||||
t.Fatalf("args: %v", args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("is null carries no argument", func(t *testing.T) {
|
||||
conds, args, err := buildDocumentFilters("deleted_at.is.null", 2)
|
||||
if err != nil || len(args) != 0 || conds[0] != "data #>> '{deleted_at}' IS NULL" {
|
||||
t.Fatalf("got %v %v", conds, args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects injection-shaped field", func(t *testing.T) {
|
||||
_, _, err := buildDocumentFilters("x' OR '1'='1.eq.a", 2)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects term without a known operator", func(t *testing.T) {
|
||||
_, _, err := buildDocumentFilters("age.between.5", 2)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid filter") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed term errors", func(t *testing.T) {
|
||||
_, _, err := buildDocumentFilters("justafield", 2)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("real columns resolve directly", func(t *testing.T) {
|
||||
conds, _, err := buildDocumentFilters("created_at.gt.2026-01-01", 2)
|
||||
// created_at is a column — but value isn't numeric so gt must fail
|
||||
if err == nil {
|
||||
t.Fatalf("expected numeric-required error, got %v", conds)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildDocumentOrder(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "created_at DESC",
|
||||
"name.asc": "data #>> '{name}' ASC",
|
||||
"name": "data #>> '{name}' ASC",
|
||||
"created_at.desc": "created_at DESC",
|
||||
"a.b.asc,id.desc": "data #>> '{a,b}' ASC, id DESC",
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := buildDocumentOrder(in)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", in, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("%q: got %q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
// A non-asc/desc suffix is a nested field path, not a direction error.
|
||||
if got, err := buildDocumentOrder("name.sideways"); err != nil || got != "data #>> '{name,sideways}' ASC" {
|
||||
t.Errorf("nested order path: got %q err %v", got, err)
|
||||
}
|
||||
if _, err := buildDocumentOrder("x;drop.asc"); err == nil {
|
||||
t.Error("expected field error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCustomEventTypeRe(t *testing.T) {
|
||||
good := []string{"custom.ping", "custom.deploy.done", "custom.a", "custom.x-y_z.9"}
|
||||
bad := []string{
|
||||
"", "custom", "custom.", "document.created", "CUSTOM.ping",
|
||||
"custom.Ping", "custom..x", "custom.-x", "custom." + string(rune(0x20)) + "x",
|
||||
"custom." + string(make([]byte, 64)),
|
||||
}
|
||||
for _, s := range good {
|
||||
if !customEventTypeRe.MatchString(s) {
|
||||
t.Errorf("expected %q to match", s)
|
||||
}
|
||||
}
|
||||
for _, s := range bad {
|
||||
if customEventTypeRe.MatchString(s) {
|
||||
t.Errorf("expected %q to be rejected", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dockerRunner executes functions in an ephemeral, locked-down container:
|
||||
// no network, read-only rootfs, memory/CPU/pid caps, non-root, all
|
||||
// capabilities dropped. The source is passed through the environment as
|
||||
// base64 and materialized inside the container's tmpfs — no bind mount,
|
||||
// so this works on Docker Desktop (VM daemon) and remote daemons where
|
||||
// host paths aren't shared. The payload arrives on stdin — same contract
|
||||
// as execRunner, but user code no longer inherits backend privileges.
|
||||
// Requires the Docker socket (compose: mount /var/run/docker.sock) — the
|
||||
// documented trade-off: socket access is root-equivalent on the host, so
|
||||
// this driver isolates function code, not operators.
|
||||
type dockerRunner struct {
|
||||
timeout time.Duration
|
||||
maxOutput int64
|
||||
images map[string]string // runtime -> image ref
|
||||
}
|
||||
|
||||
// maxFnSourceBytes caps function source for the docker driver — the source
|
||||
// travels in the container spec (env), which kernels limit to ~2 MB total;
|
||||
// 256 KiB keeps argv+env comfortably inside it.
|
||||
const maxFnSourceBytes = 256 << 10
|
||||
|
||||
func NewDockerRunner(timeout time.Duration, maxOutput int64) *dockerRunner {
|
||||
return &dockerRunner{
|
||||
timeout: timeout,
|
||||
maxOutput: maxOutput,
|
||||
images: map[string]string{
|
||||
"bun": "oven/bun:1.3.14",
|
||||
"deno": "denoland/deno:2.5.6",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *dockerRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult {
|
||||
image, ok := r.images[runtime]
|
||||
if !ok {
|
||||
return FunctionResult{Status: "error", Stderr: "no container image configured for runtime " + runtime}
|
||||
}
|
||||
if len(code) > maxFnSourceBytes {
|
||||
return FunctionResult{Status: "error", Stderr: fmt.Sprintf("function source exceeds %d KiB docker-driver limit", maxFnSourceBytes>>10)}
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
return FunctionResult{Status: "error", Stderr: "FUNCTIONS_DRIVER=docker but docker CLI not found"}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, r.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Named container so a timed-out client doesn't orphan a running fn.
|
||||
var suffix [4]byte
|
||||
_, _ = rand.Read(suffix[:])
|
||||
name := "primora-fn-" + hex.EncodeToString(suffix[:])
|
||||
|
||||
// Write source inside the container, then run it. base64 keeps the
|
||||
// code inert data — it is decoded by the shell, never interpreted.
|
||||
entrypoint := fmt.Sprintf(
|
||||
`echo "$PRIMORA_CODE" | base64 -d > /tmp/fn.ts && %s /tmp/fn.ts`,
|
||||
map[bool]string{true: "deno run --no-prompt", false: "bun run"}[runtime == "deno"],
|
||||
)
|
||||
|
||||
args := []string{
|
||||
"run", "--rm", "-i", "--name", name,
|
||||
"--network", "none",
|
||||
"--memory", "128m", "--memory-swap", "128m",
|
||||
"--cpus", "0.5",
|
||||
"--pids-limit", "64",
|
||||
"--read-only",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=16m",
|
||||
"--cap-drop", "ALL",
|
||||
"--security-opt", "no-new-privileges",
|
||||
"--user", "65534:65534",
|
||||
"-e", "PRIMORA_CODE=" + base64.StdEncoding.EncodeToString([]byte(code)),
|
||||
"-e", "PRIMORA_PAYLOAD=" + string(payload),
|
||||
"-e", "PRIMORA_FUNCTION_RUNTIME=" + runtime,
|
||||
"--entrypoint", "sh",
|
||||
image, "-c", entrypoint,
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Stdin = bytes.NewReader(payload)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput}
|
||||
cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput}
|
||||
|
||||
// ctx cancel kills the docker CLI but the daemon keeps the container
|
||||
// running — force-remove it so a stuck function can't linger.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = exec.Command("docker", "rm", "-f", name).Run()
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
defer close(done)
|
||||
|
||||
start := time.Now()
|
||||
runErr := cmd.Run()
|
||||
duration := time.Since(start).Milliseconds()
|
||||
|
||||
res := FunctionResult{
|
||||
Status: "success",
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
DurationMs: duration,
|
||||
}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
res.Status = "timeout"
|
||||
res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out")
|
||||
return res
|
||||
}
|
||||
if runErr != nil {
|
||||
res.Status = "error"
|
||||
if exitErr, ok := runErr.(*exec.ExitError); ok {
|
||||
code := int32(exitErr.ExitCode())
|
||||
res.ExitCode = &code
|
||||
} else {
|
||||
res.Stderr = strings.TrimSpace(fmt.Sprintf("%v\n%s", runErr, res.Stderr))
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func dockerAvailable(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker CLI not installed")
|
||||
}
|
||||
if err := exec.Command("docker", "info").Run(); err != nil {
|
||||
t.Skip("docker daemon not reachable")
|
||||
}
|
||||
// The test pulls the runtime image on first run.
|
||||
if err := exec.Command("docker", "image", "inspect", "oven/bun:1.3.14").Run(); err != nil {
|
||||
t.Skip("oven/bun:1.3.14 image not pulled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRunnerRunsCode(t *testing.T) {
|
||||
dockerAvailable(t)
|
||||
r := NewDockerRunner(30*time.Second, 65536)
|
||||
res := r.Run(context.Background(), "bun",
|
||||
`const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("hi " + p.name);`,
|
||||
json.RawMessage(`{"name":"sandbox"}`))
|
||||
if res.Status != "success" {
|
||||
t.Fatalf("status = %q stderr = %q", res.Status, res.Stderr)
|
||||
}
|
||||
if !strings.Contains(res.Stdout, "hi sandbox") {
|
||||
t.Fatalf("stdout = %q", res.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRunnerIsolatesNetwork(t *testing.T) {
|
||||
dockerAvailable(t)
|
||||
r := NewDockerRunner(30*time.Second, 65536)
|
||||
// --network none: even a DNS/connect attempt must fail inside.
|
||||
res := r.Run(context.Background(), "bun",
|
||||
`try { await fetch("http://169.254.169.254/"); console.log("NET-OPEN") } catch { console.log("NET-CLOSED") }`,
|
||||
json.RawMessage(`{}`))
|
||||
if !strings.Contains(res.Stdout, "NET-CLOSED") {
|
||||
t.Fatalf("network should be unreachable, stdout = %q stderr = %q status = %q", res.Stdout, res.Stderr, res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRunnerTimesOut(t *testing.T) {
|
||||
dockerAvailable(t)
|
||||
r := NewDockerRunner(2*time.Second, 65536)
|
||||
res := r.Run(context.Background(), "bun", `while (true) {}`, json.RawMessage(`{}`))
|
||||
if res.Status != "timeout" {
|
||||
t.Fatalf("status = %q", res.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||
)
|
||||
|
||||
var functionNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`)
|
||||
|
||||
// FunctionRunner executes a function's code with a JSON payload on stdin
|
||||
// and returns the outcome. Implemented by execRunner (a runtime binary on
|
||||
// the backend host); tests substitute a fake.
|
||||
type FunctionRunner interface {
|
||||
Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult
|
||||
}
|
||||
|
||||
// FunctionResult is one execution's captured outcome.
|
||||
type FunctionResult struct {
|
||||
Status string // success | error | timeout
|
||||
ExitCode *int32
|
||||
Stdout string
|
||||
Stderr string
|
||||
DurationMs int64
|
||||
}
|
||||
|
||||
// execRunner shells out to a JS runtime binary. User code runs with the
|
||||
// backend's privileges — this is the documented self-hosted model (same as
|
||||
// n8n custom-code nodes); operators harden at the container/VM boundary.
|
||||
// jarvis: ceiling unsandboxed exec; upgrade to Firecracker/gVisor or a
|
||||
// sidecar runtime pool when multi-tenant isolation is needed.
|
||||
type execRunner struct {
|
||||
bin string
|
||||
timeout time.Duration
|
||||
maxOutput int64
|
||||
resolveBin func(runtime string) string
|
||||
}
|
||||
|
||||
func NewExecRunner(bin string, timeout time.Duration, maxOutput int64) *execRunner {
|
||||
return &execRunner{
|
||||
bin: bin,
|
||||
timeout: timeout,
|
||||
maxOutput: maxOutput,
|
||||
resolveBin: func(runtime string) string {
|
||||
if bin != "" && bin != "auto" {
|
||||
return bin
|
||||
}
|
||||
return runtime // "bun" or "deno" from PATH
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *execRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult {
|
||||
bin := r.resolveBin(runtime)
|
||||
if bin == "" {
|
||||
return FunctionResult{Status: "error", Stderr: "no functions runtime configured"}
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", "primora-fn-*")
|
||||
if err != nil {
|
||||
return FunctionResult{Status: "error", Stderr: err.Error()}
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
file := filepath.Join(dir, "fn.ts")
|
||||
if err := os.WriteFile(file, []byte(code), 0o600); err != nil {
|
||||
return FunctionResult{Status: "error", Stderr: err.Error()}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, r.timeout)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch runtime {
|
||||
case "deno":
|
||||
// --no-prompt: never block on interactive permission prompts.
|
||||
cmd = exec.CommandContext(ctx, bin, "run", "--no-prompt", file)
|
||||
default: // bun
|
||||
cmd = exec.CommandContext(ctx, bin, "run", file)
|
||||
}
|
||||
cmd.Stdin = bytes.NewReader(payload)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PRIMORA_PAYLOAD="+string(payload),
|
||||
"PRIMORA_FUNCTION_RUNTIME="+runtime,
|
||||
)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput}
|
||||
cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput}
|
||||
|
||||
start := time.Now()
|
||||
runErr := cmd.Run()
|
||||
duration := time.Since(start).Milliseconds()
|
||||
|
||||
res := FunctionResult{
|
||||
Status: "success",
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
DurationMs: duration,
|
||||
}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
res.Status = "timeout"
|
||||
res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out")
|
||||
return res
|
||||
}
|
||||
if runErr != nil {
|
||||
res.Status = "error"
|
||||
if exitErr, ok := runErr.(*exec.ExitError); ok {
|
||||
code := int32(exitErr.ExitCode())
|
||||
res.ExitCode = &code
|
||||
} else {
|
||||
res.Stderr = strings.TrimSpace(runErr.Error() + "\n" + res.Stderr)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// limitedWriter caps captured output; excess bytes are discarded, not
|
||||
// stored — a chatty function can't grow function_runs rows unboundedly.
|
||||
type limitedWriter struct {
|
||||
w *bytes.Buffer
|
||||
max int64
|
||||
}
|
||||
|
||||
func (l *limitedWriter) Write(p []byte) (int, error) {
|
||||
remaining := l.max - int64(l.w.Len())
|
||||
if remaining > 0 {
|
||||
if int64(len(p)) > remaining {
|
||||
l.w.Write(p[:remaining])
|
||||
} else {
|
||||
l.w.Write(p)
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// --- service layer -------------------------------------------------------
|
||||
|
||||
type CreateFunctionInput struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Code string `json:"code" validate:"required,max=262144"`
|
||||
Runtime string `json:"runtime" validate:"omitempty,oneof=bun deno"`
|
||||
EventPattern string `json:"event_pattern" validate:"omitempty,max=128"`
|
||||
}
|
||||
|
||||
type UpdateFunctionInput struct {
|
||||
Code *string `json:"code" validate:"omitempty,max=262144"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
EventPattern *string `json:"event_pattern" validate:"omitempty,max=128"`
|
||||
}
|
||||
|
||||
type FunctionSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Runtime string `json:"runtime"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EventPattern string `json:"event_pattern,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type FunctionRunSummary struct {
|
||||
ID string `json:"id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
ExitCode *int32 `json:"exit_code,omitempty"`
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func functionSummary(r db.CoreFunction) FunctionSummary {
|
||||
return FunctionSummary{
|
||||
ID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
Runtime: r.Runtime,
|
||||
Enabled: r.Enabled,
|
||||
EventPattern: r.EventPattern,
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
UpdatedAt: r.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func runSummary(r db.CoreFunctionRun) FunctionRunSummary {
|
||||
return FunctionRunSummary{
|
||||
ID: r.ID.String(),
|
||||
Trigger: r.Trigger,
|
||||
Status: r.Status,
|
||||
ExitCode: r.ExitCode,
|
||||
Stdout: r.Stdout,
|
||||
Stderr: r.Stderr,
|
||||
DurationMs: int64(r.DurationMs),
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PlatformService) CreateFunction(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateFunctionInput, requestID string) (FunctionSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return FunctionSummary{}, err
|
||||
}
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if !functionNamePattern.MatchString(name) {
|
||||
return FunctionSummary{}, fmt.Errorf("invalid function name")
|
||||
}
|
||||
runtime := input.Runtime
|
||||
if runtime == "" {
|
||||
runtime = "bun"
|
||||
}
|
||||
row, err := s.repo.Queries().CreateFunction(ctx, db.CreateFunctionParams{
|
||||
ProjectID: projectID,
|
||||
Name: name,
|
||||
Code: input.Code,
|
||||
Runtime: runtime,
|
||||
EventPattern: strings.TrimSpace(input.EventPattern),
|
||||
})
|
||||
if err != nil {
|
||||
return FunctionSummary{}, err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "function.created", "function", row.ID.String(), map[string]any{
|
||||
"name": row.Name, "runtime": row.Runtime,
|
||||
}))
|
||||
}
|
||||
return functionSummary(row), nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListFunctions(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]FunctionSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.repo.Queries().ListFunctions(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FunctionSummary, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = functionSummary(r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetFunctionCode returns the function's source — code is not a secret,
|
||||
// project members already see it in the dashboard editor.
|
||||
func (s *PlatformService) GetFunctionCode(ctx context.Context, actor *models.Actor, functionID uuid.UUID) (FunctionSummary, string, error) {
|
||||
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||
if err != nil {
|
||||
return FunctionSummary{}, "", err
|
||||
}
|
||||
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil {
|
||||
return FunctionSummary{}, "", err
|
||||
}
|
||||
return functionSummary(row), row.Code, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) UpdateFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, input UpdateFunctionInput, requestID string) (FunctionSummary, error) {
|
||||
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||
if err != nil {
|
||||
return FunctionSummary{}, err
|
||||
}
|
||||
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||
return FunctionSummary{}, err
|
||||
}
|
||||
var pattern *string
|
||||
if input.EventPattern != nil {
|
||||
p := strings.TrimSpace(*input.EventPattern)
|
||||
pattern = &p
|
||||
}
|
||||
updated, err := s.repo.Queries().UpdateFunction(ctx, db.UpdateFunctionParams{
|
||||
ID: functionID,
|
||||
ProjectID: row.ProjectID,
|
||||
Code: input.Code,
|
||||
Enabled: input.Enabled,
|
||||
EventPattern: pattern,
|
||||
})
|
||||
if err != nil {
|
||||
return FunctionSummary{}, err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.updated", "function", functionID.String(), map[string]any{
|
||||
"name": updated.Name,
|
||||
}))
|
||||
}
|
||||
return functionSummary(updated), nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) DeleteFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, requestID string) error {
|
||||
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.Queries().DeleteFunction(ctx, db.DeleteFunctionParams{
|
||||
ID: functionID,
|
||||
ProjectID: row.ProjectID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.deleted", "function", functionID.String(), map[string]any{
|
||||
"name": row.Name,
|
||||
}))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvokeFunction executes a function with a JSON payload, records the run,
|
||||
// and returns its summary.
|
||||
func (s *PlatformService) InvokeFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, payload json.RawMessage, requestID string) (FunctionRunSummary, error) {
|
||||
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||
if err != nil {
|
||||
return FunctionRunSummary{}, err
|
||||
}
|
||||
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil {
|
||||
return FunctionRunSummary{}, err
|
||||
}
|
||||
summary, err := s.executeFunction(ctx, row, payload, "manual")
|
||||
if err != nil {
|
||||
return FunctionRunSummary{}, err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, row.ProjectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, row.ProjectID, actor, requestID, "function.invoked", "function", functionID.String(), map[string]any{
|
||||
"name": row.Name, "status": summary.Status,
|
||||
}))
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// executeFunction runs a function row and records the outcome — shared by
|
||||
// manual invoke, schedule/hook delivery, and event triggers (no role check;
|
||||
// callers hold their own auth context or are system paths).
|
||||
func (s *PlatformService) executeFunction(ctx context.Context, row db.CoreFunction, payload json.RawMessage, trigger string) (FunctionRunSummary, error) {
|
||||
if !row.Enabled {
|
||||
return FunctionRunSummary{}, fmt.Errorf("function is disabled")
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
payload = json.RawMessage(`{}`)
|
||||
}
|
||||
if !json.Valid(payload) {
|
||||
return FunctionRunSummary{}, fmt.Errorf("payload must be valid JSON")
|
||||
}
|
||||
if s.functions == nil {
|
||||
return FunctionRunSummary{}, fmt.Errorf("functions runtime not configured")
|
||||
}
|
||||
result := s.functions.Run(ctx, row.Runtime, row.Code, payload)
|
||||
var exit *int32
|
||||
if result.ExitCode != nil {
|
||||
exit = result.ExitCode
|
||||
}
|
||||
run, err := s.repo.Queries().InsertFunctionRun(ctx, db.InsertFunctionRunParams{
|
||||
FunctionID: row.ID,
|
||||
Trigger: trigger,
|
||||
Status: result.Status,
|
||||
ExitCode: exit,
|
||||
Stdout: result.Stdout,
|
||||
Stderr: result.Stderr,
|
||||
DurationMs: int32(result.DurationMs),
|
||||
})
|
||||
if err != nil {
|
||||
return FunctionRunSummary{}, err
|
||||
}
|
||||
return runSummary(run), nil
|
||||
}
|
||||
|
||||
// runJobFunction is the scheduler's delivery path for function-targeted
|
||||
// jobs: load the function, run it with the resolved payload, record the
|
||||
// run. The job run's status_code mirrors the process exit code.
|
||||
func (s *PlatformService) runJobFunction(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error) {
|
||||
fn, err := s.repo.Queries().GetFunction(ctx, uuid.UUID(job.FunctionID.Bytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("target function missing: %w", err)
|
||||
}
|
||||
summary, err := s.executeFunction(ctx, fn, payload, triggerFromJobRun(run.TriggeredBy))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code := int32(0)
|
||||
if summary.ExitCode != nil {
|
||||
code = *summary.ExitCode
|
||||
}
|
||||
if summary.Status != "success" {
|
||||
return &code, fmt.Errorf("function %q %s: %s", fn.Name, summary.Status, truncate(summary.Stderr, 200))
|
||||
}
|
||||
return &code, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
func triggerFromJobRun(triggeredBy string) string {
|
||||
switch triggeredBy {
|
||||
case "schedule", "hook", "manual":
|
||||
return triggeredBy
|
||||
default:
|
||||
return "schedule"
|
||||
}
|
||||
}
|
||||
|
||||
// triggerFunctionsForEvent fans a domain event out to every enabled
|
||||
// function whose event_pattern matches ("document.*", "*", …). Runs are
|
||||
// detached — event publishers never block on user code.
|
||||
// jarvis: ceiling pattern is a single * suffix/prefix wildcard via SQL
|
||||
// LIKE; upgrade to a real matcher if multi-segment globs are needed.
|
||||
func (s *PlatformService) triggerFunctionsForEvent(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
|
||||
if s.functions == nil {
|
||||
return
|
||||
}
|
||||
rows, err := s.repo.Queries().ListFunctionsForEvent(ctx, db.ListFunctionsForEventParams{
|
||||
ProjectID: projectID,
|
||||
EventPattern: eventType,
|
||||
})
|
||||
if err != nil || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event": eventType,
|
||||
"project_id": projectID.String(),
|
||||
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"data": data,
|
||||
})
|
||||
for _, fn := range rows {
|
||||
fn := fn
|
||||
go func() {
|
||||
// Detached context: the publishing request may finish first.
|
||||
runCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := s.executeFunction(runCtx, fn, payload, "event"); err != nil {
|
||||
s.logger.Warn("function event trigger failed", "function", fn.Name, "event", eventType, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListFunctionRuns(ctx context.Context, actor *models.Actor, functionID uuid.UUID, limit int64) ([]FunctionRunSummary, error) {
|
||||
row, err := s.repo.Queries().GetFunction(ctx, functionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.repo.Queries().ListFunctionRuns(ctx, db.ListFunctionRunsParams{
|
||||
FunctionID: row.ID,
|
||||
Limit: int32(limit),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]FunctionRunSummary, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = runSummary(r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// These tests run the real bun binary — skipped when it is not installed,
|
||||
// same convention as exec-dependent tests elsewhere.
|
||||
func bunAvailable(t *testing.T) {
|
||||
t.Helper()
|
||||
r := NewExecRunner("auto", 5*time.Second, 4096)
|
||||
res := r.Run(context.Background(), "bun", `console.log("probe")`, nil)
|
||||
if strings.Contains(res.Stderr, "no functions runtime") || (res.Status == "error" && res.ExitCode == nil && strings.Contains(res.Stderr, "executable file not found")) {
|
||||
t.Skip("bun not installed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecRunnerSuccess(t *testing.T) {
|
||||
bunAvailable(t)
|
||||
r := NewExecRunner("auto", 10*time.Second, 4096)
|
||||
res := r.Run(context.Background(), "bun",
|
||||
`const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("got:" + p.x);`,
|
||||
[]byte(`{"x":42}`))
|
||||
if res.Status != "success" {
|
||||
t.Fatalf("status=%s stderr=%s", res.Status, res.Stderr)
|
||||
}
|
||||
if !strings.Contains(res.Stdout, "got:42") {
|
||||
t.Fatalf("stdout=%q", res.Stdout)
|
||||
}
|
||||
if res.DurationMs <= 0 {
|
||||
t.Fatalf("duration=%d", res.DurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecRunnerErrorCapturesStderr(t *testing.T) {
|
||||
bunAvailable(t)
|
||||
r := NewExecRunner("auto", 10*time.Second, 4096)
|
||||
res := r.Run(context.Background(), "bun", `throw new Error("test-boom")`, nil)
|
||||
if res.Status != "error" {
|
||||
t.Fatalf("status=%s", res.Status)
|
||||
}
|
||||
if res.ExitCode == nil || *res.ExitCode == 0 {
|
||||
t.Fatalf("exit=%v", res.ExitCode)
|
||||
}
|
||||
if !strings.Contains(res.Stderr, "test-boom") {
|
||||
t.Fatalf("stderr=%q", res.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecRunnerTimeout(t *testing.T) {
|
||||
bunAvailable(t)
|
||||
r := NewExecRunner("auto", 500*time.Millisecond, 4096)
|
||||
res := r.Run(context.Background(), "bun", `await new Promise(r => setTimeout(r, 10000))`, nil)
|
||||
if res.Status != "timeout" {
|
||||
t.Fatalf("status=%s", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitedWriterCaps(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
lw := &limitedWriter{w: &buf, max: 5}
|
||||
if n, err := lw.Write([]byte("hello world")); err != nil || n != 11 {
|
||||
t.Fatalf("write n=%d err=%v", n, err)
|
||||
}
|
||||
if buf.String() != "hello" {
|
||||
t.Fatalf("got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerFromJobRun(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"schedule": "schedule",
|
||||
"hook": "hook",
|
||||
"manual": "manual",
|
||||
"cli": "schedule",
|
||||
"": "schedule",
|
||||
} {
|
||||
if got := triggerFromJobRun(in); got != want {
|
||||
t.Fatalf("triggerFromJobRun(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||
)
|
||||
|
||||
// Inbound hooks are project-scoped public ingest endpoints:
|
||||
// POST /api/v1/hooks/<token>. A hook either republishes the body as an
|
||||
// `inbound.received` domain event (webhooks + realtime fan out for free) or
|
||||
// triggers a scheduled-job run with the body as the payload. When a secret is
|
||||
// set, callers must sign the raw body with X-Primora-Signature — the same
|
||||
// HMAC-SHA256 scheme outbound deliveries use.
|
||||
|
||||
const (
|
||||
WebhookEventInboundReceived = "inbound.received"
|
||||
|
||||
InboundModeEvent = "event"
|
||||
InboundModeJob = "job"
|
||||
InboundModeFunction = "function"
|
||||
)
|
||||
|
||||
type InboundHookSummary struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
URL string `json:"url"`
|
||||
Mode string `json:"mode"`
|
||||
JobID string `json:"job_id,omitempty"`
|
||||
FunctionID string `json:"function_id,omitempty"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastReceivedAt *time.Time `json:"last_received_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateInboundHookInput struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Mode string `json:"mode"`
|
||||
JobID string `json:"job_id"`
|
||||
FunctionID string `json:"function_id"`
|
||||
Secret string `json:"secret"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type InboundReceiveResult struct {
|
||||
Received bool `json:"received"`
|
||||
Event string `json:"event,omitempty"`
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
}
|
||||
|
||||
func inboundHookSummary(row db.CoreInboundHook, publicURL string) InboundHookSummary {
|
||||
out := InboundHookSummary{
|
||||
ID: row.ID.String(),
|
||||
ProjectID: row.ProjectID.String(),
|
||||
Name: row.Name,
|
||||
Token: row.Token,
|
||||
URL: strings.TrimSuffix(publicURL, "/") + "/api/v1/hooks/" + row.Token,
|
||||
Mode: row.Mode,
|
||||
HasSecret: len(row.Secret) > 0,
|
||||
Enabled: row.Enabled,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
}
|
||||
if row.JobID.Valid {
|
||||
out.JobID = uuid.UUID(row.JobID.Bytes).String()
|
||||
}
|
||||
if row.FunctionID.Valid {
|
||||
out.FunctionID = uuid.UUID(row.FunctionID.Bytes).String()
|
||||
}
|
||||
if row.LastReceivedAt.Valid {
|
||||
t := row.LastReceivedAt.Time
|
||||
out.LastReceivedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListInboundHooks(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]InboundHookSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.repo.Queries().ListInboundHooks(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]InboundHookSummary, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = inboundHookSummary(r, s.publicURL)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) CreateInboundHook(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateInboundHookInput, requestID string) (InboundHookSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return InboundHookSummary{}, err
|
||||
}
|
||||
if !projectSecretNameRe.MatchString(input.Name) {
|
||||
return InboundHookSummary{}, fmt.Errorf("invalid hook name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name)
|
||||
}
|
||||
mode := input.Mode
|
||||
if mode == "" {
|
||||
mode = InboundModeEvent
|
||||
}
|
||||
var jobID, functionID pgtype.UUID
|
||||
switch mode {
|
||||
case InboundModeJob:
|
||||
parsed, err := uuid.Parse(input.JobID)
|
||||
if err != nil {
|
||||
return InboundHookSummary{}, errors.New("job mode requires a valid job_id")
|
||||
}
|
||||
job, err := s.repo.Queries().GetScheduledJobByID(ctx, parsed)
|
||||
if err != nil || job.ProjectID != projectID {
|
||||
return InboundHookSummary{}, errors.New("job not found in this project")
|
||||
}
|
||||
jobID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
case InboundModeFunction:
|
||||
parsed, err := uuid.Parse(input.FunctionID)
|
||||
if err != nil {
|
||||
return InboundHookSummary{}, errors.New("function mode requires a valid function_id")
|
||||
}
|
||||
fn, err := s.repo.Queries().GetFunction(ctx, parsed)
|
||||
if err != nil || fn.ProjectID != projectID {
|
||||
return InboundHookSummary{}, errors.New("function not found in this project")
|
||||
}
|
||||
functionID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
case InboundModeEvent:
|
||||
default:
|
||||
return InboundHookSummary{}, fmt.Errorf("invalid mode %q — event, job, or function", input.Mode)
|
||||
}
|
||||
secretBytes := []byte{}
|
||||
if input.Secret != "" {
|
||||
if s.enc == nil {
|
||||
return InboundHookSummary{}, errors.New("encryption is not configured — cannot store hook secrets")
|
||||
}
|
||||
var err error
|
||||
if secretBytes, err = s.enc.Encrypt([]byte(input.Secret)); err != nil {
|
||||
return InboundHookSummary{}, err
|
||||
}
|
||||
}
|
||||
token := "prm_hook_" + randHex(16)
|
||||
enabled := true
|
||||
if input.Enabled != nil {
|
||||
enabled = *input.Enabled
|
||||
}
|
||||
row, err := s.repo.Queries().CreateInboundHook(ctx, db.CreateInboundHookParams{
|
||||
ProjectID: projectID,
|
||||
Name: input.Name,
|
||||
Token: token,
|
||||
Mode: mode,
|
||||
JobID: jobID,
|
||||
FunctionID: functionID,
|
||||
Secret: secretBytes,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "inbound_hooks_project_id_name_key") {
|
||||
return InboundHookSummary{}, fmt.Errorf("an inbound hook named %s already exists", input.Name)
|
||||
}
|
||||
return InboundHookSummary{}, err
|
||||
}
|
||||
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||
if err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.created", "inbound_hook", row.ID.String(), map[string]any{
|
||||
"name": row.Name, "mode": row.Mode,
|
||||
}))
|
||||
}
|
||||
return inboundHookSummary(row, s.publicURL), nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) DeleteInboundHook(ctx context.Context, actor *models.Actor, projectID, hookID uuid.UUID, requestID string) error {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.repo.Queries().GetInboundHook(ctx, db.GetInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.Queries().DeleteInboundHook(ctx, db.DeleteInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||
if err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.deleted", "inbound_hook", hookID.String(), nil))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReceiveInboundHook handles POST /api/v1/hooks/:token — unauthenticated; the
|
||||
// token is the credential. Signature check applies when a secret is set.
|
||||
func (s *PlatformService) ReceiveInboundHook(ctx context.Context, token string, signature string, body []byte) (InboundReceiveResult, error) {
|
||||
hook, err := s.repo.Queries().GetInboundHookByToken(ctx, token)
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err // pgx.ErrNoRows → 404
|
||||
}
|
||||
if !hook.Enabled {
|
||||
return InboundReceiveResult{}, errors.New("hook is disabled")
|
||||
}
|
||||
if len(hook.Secret) > 0 {
|
||||
if s.enc == nil {
|
||||
return InboundReceiveResult{}, errors.New("encryption is not configured")
|
||||
}
|
||||
plain, err := s.enc.Decrypt(hook.Secret)
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
want := "sha256=" + signBody(string(plain), body)
|
||||
if subtle.ConstantTimeCompare([]byte(signature), []byte(want)) != 1 {
|
||||
return InboundReceiveResult{}, errors.New("invalid signature")
|
||||
}
|
||||
}
|
||||
var payload map[string]any
|
||||
if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return InboundReceiveResult{}, fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
}
|
||||
_ = s.repo.Queries().TouchInboundHook(ctx, hook.ID)
|
||||
|
||||
switch hook.Mode {
|
||||
case InboundModeJob:
|
||||
if s.scheduler == nil {
|
||||
return InboundReceiveResult{}, errors.New("scheduler unavailable")
|
||||
}
|
||||
if !hook.JobID.Valid {
|
||||
return InboundReceiveResult{}, errors.New("hook has no job configured")
|
||||
}
|
||||
job, err := s.repo.Queries().GetScheduledJobByID(ctx, hook.JobID.Bytes)
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
if payload != nil {
|
||||
raw, _ := json.Marshal(payload)
|
||||
job.Payload = raw // received body replaces the configured payload
|
||||
}
|
||||
run, err := s.scheduler.enqueue(ctx, job, "hook")
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
return InboundReceiveResult{Received: true, Event: WebhookEventJobRun, RunID: run.ID.String()}, nil
|
||||
case InboundModeFunction:
|
||||
if s.functions == nil {
|
||||
return InboundReceiveResult{}, errors.New("functions runtime not configured")
|
||||
}
|
||||
if !hook.FunctionID.Valid {
|
||||
return InboundReceiveResult{}, errors.New("hook has no function configured")
|
||||
}
|
||||
fn, err := s.repo.Queries().GetFunction(ctx, uuid.UUID(hook.FunctionID.Bytes))
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
raw := body
|
||||
if len(raw) == 0 {
|
||||
raw = []byte(`{}`)
|
||||
}
|
||||
// Detached: the public endpoint acks immediately; the run row records
|
||||
// the outcome. Hook callers must not wait on user code.
|
||||
go func() {
|
||||
runCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := s.executeFunction(runCtx, fn, raw, "hook"); err != nil && s.logger != nil {
|
||||
s.logger.Warn("hook function invoke failed", "hook", hook.Name, "function", fn.Name, "error", err)
|
||||
}
|
||||
}()
|
||||
return InboundReceiveResult{Received: true, Event: "function.invoked"}, nil
|
||||
default:
|
||||
s.publishEvent(ctx, hook.ProjectID, WebhookEventInboundReceived, map[string]any{
|
||||
"hook_id": hook.ID.String(),
|
||||
"hook_name": hook.Name,
|
||||
"payload": payload,
|
||||
})
|
||||
return InboundReceiveResult{Received: true, Event: WebhookEventInboundReceived}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -104,4 +104,39 @@ func TestValidateWebhookEvents(t *testing.T) {
|
||||
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
|
||||
t.Error("expected unknown event rejected")
|
||||
}
|
||||
for _, ok := range [][]string{
|
||||
{"*"},
|
||||
{"document.*"},
|
||||
{"custom.deploy.done"},
|
||||
{"custom.*"},
|
||||
{"custom.deploy.*"},
|
||||
} {
|
||||
if _, err := validateWebhookEvents(ok); err != nil {
|
||||
t.Errorf("expected %v accepted, got %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range [][]string{
|
||||
{"banana.*"}, {"custom..*"}, {"custom.UPPER"}, {"document.exploded"},
|
||||
} {
|
||||
if _, err := validateWebhookEvents(bad); err == nil {
|
||||
t.Errorf("expected %v rejected", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateWebhookHostHardening(t *testing.T) {
|
||||
private := []string{"localhost", "minio", "echo-svc", "host.docker.internal",
|
||||
"10.0.0.4", "172.16.5.5", "192.168.1.10", "fe80::1", "svc.local", "db.internal"}
|
||||
public := []string{"169.254.169.254", "fd00:ec2::254", "2130706433", "0x7f000001",
|
||||
"8.8.8.8", "example.com", "999999999999"}
|
||||
for _, h := range private {
|
||||
if !isPrivateWebhookHost(h) {
|
||||
t.Errorf("expected %q private", h)
|
||||
}
|
||||
}
|
||||
for _, h := range public {
|
||||
if isPrivateWebhookHost(h) {
|
||||
t.Errorf("expected %q public/blocked", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
@@ -34,6 +35,7 @@ type ScheduledJobSummary struct {
|
||||
Name string `json:"name"`
|
||||
Schedule string `json:"schedule"`
|
||||
URL string `json:"url"`
|
||||
FunctionID *string `json:"function_id,omitempty"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
@@ -58,21 +60,23 @@ type ScheduledJobRunSummary struct {
|
||||
}
|
||||
|
||||
type CreateScheduledJobInput struct {
|
||||
Name string `json:"name" validate:"required,min=2"`
|
||||
Schedule string `json:"schedule" validate:"required"`
|
||||
URL string `json:"url" validate:"required,url"`
|
||||
Secret string `json:"secret"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name" validate:"required,min=2"`
|
||||
Schedule string `json:"schedule" validate:"required"`
|
||||
URL string `json:"url" validate:"required_without=FunctionID,omitempty,url"`
|
||||
FunctionID string `json:"function_id" validate:"omitempty,uuid"`
|
||||
Secret string `json:"secret"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type UpdateScheduledJobInput struct {
|
||||
Name *string `json:"name" validate:"omitempty,min=2"`
|
||||
Schedule *string `json:"schedule"`
|
||||
URL *string `json:"url" validate:"omitempty,url"`
|
||||
Secret *string `json:"secret"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name *string `json:"name" validate:"omitempty,min=2"`
|
||||
Schedule *string `json:"schedule"`
|
||||
URL *string `json:"url" validate:"omitempty,url"`
|
||||
FunctionID *string `json:"function_id" validate:"omitempty,uuid"`
|
||||
Secret *string `json:"secret"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
|
||||
@@ -87,6 +91,10 @@ func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
|
||||
HasSecret: len(row.Secret) > 0,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
}
|
||||
if row.FunctionID.Valid {
|
||||
id := uuid.UUID(row.FunctionID.Bytes).String()
|
||||
out.FunctionID = &id
|
||||
}
|
||||
if row.LastRunAt.Valid {
|
||||
t := row.LastRunAt.Time
|
||||
out.LastRunAt = &t
|
||||
@@ -175,9 +183,25 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return ScheduledJobSummary{}, err
|
||||
}
|
||||
jobURL, err := normalizeWebhookURL(input.URL)
|
||||
if err != nil {
|
||||
return ScheduledJobSummary{}, err
|
||||
jobURL := ""
|
||||
var functionID pgtype.UUID
|
||||
if input.FunctionID != "" {
|
||||
fid, err := uuid.Parse(input.FunctionID)
|
||||
if err != nil {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
|
||||
}
|
||||
fn, err := s.repo.Queries().GetFunction(ctx, fid)
|
||||
if err != nil || fn.ProjectID != projectID {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
|
||||
}
|
||||
functionID = pgtype.UUID{Bytes: fid, Valid: true}
|
||||
jobURL = "function://" + fid.String()
|
||||
} else {
|
||||
var err error
|
||||
jobURL, err = normalizeWebhookURL(input.URL)
|
||||
if err != nil {
|
||||
return ScheduledJobSummary{}, err
|
||||
}
|
||||
}
|
||||
sched, err := parseSchedule(input.Schedule)
|
||||
if err != nil {
|
||||
@@ -221,6 +245,7 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
|
||||
Enabled: enabled,
|
||||
NextRunAt: next,
|
||||
CreatedByUserID: createdBy,
|
||||
FunctionID: functionID,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
||||
@@ -259,6 +284,28 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
|
||||
return ScheduledJobSummary{}, err
|
||||
}
|
||||
}
|
||||
rotateFunction := input.FunctionID != nil
|
||||
functionID := row.FunctionID
|
||||
if rotateFunction {
|
||||
raw := strings.TrimSpace(*input.FunctionID)
|
||||
if raw == "" {
|
||||
functionID = pgtype.UUID{} // explicit clear → back to URL delivery
|
||||
if input.URL == nil && strings.HasPrefix(jobURL, "function://") {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("clearing function_id requires a url")
|
||||
}
|
||||
} else {
|
||||
fid, ferr := uuid.Parse(raw)
|
||||
if ferr != nil {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
|
||||
}
|
||||
fn, ferr := s.repo.Queries().GetFunction(ctx, fid)
|
||||
if ferr != nil || fn.ProjectID != projectID {
|
||||
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
|
||||
}
|
||||
functionID = pgtype.UUID{Bytes: fid, Valid: true}
|
||||
jobURL = "function://" + fid.String()
|
||||
}
|
||||
}
|
||||
enabled := row.Enabled
|
||||
if input.Enabled != nil {
|
||||
enabled = *input.Enabled
|
||||
@@ -301,6 +348,8 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
|
||||
Secret: secretBytes,
|
||||
Column10: rotatePayload,
|
||||
Payload: payload,
|
||||
Column12: rotateFunction,
|
||||
FunctionID: functionID,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
||||
@@ -393,6 +442,7 @@ type jobQueries interface {
|
||||
FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error)
|
||||
MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error)
|
||||
PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error
|
||||
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]db.ListProjectSecretValuesRow, error)
|
||||
}
|
||||
|
||||
type JobScheduler struct {
|
||||
@@ -405,8 +455,13 @@ type JobScheduler struct {
|
||||
quit chan struct{}
|
||||
wg sync.WaitGroup
|
||||
started sync.Once
|
||||
leader *leaderState
|
||||
// onEvent fans a finished run out to webhooks and the realtime stream.
|
||||
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
|
||||
// runFn delivers to a function instead of HTTP when job.FunctionID is set.
|
||||
// The function receives the resolved job payload, not the run envelope.
|
||||
// Owned by PlatformService — the scheduler stays transport-agnostic.
|
||||
runFn func(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error)
|
||||
}
|
||||
|
||||
type jobExec struct {
|
||||
@@ -414,7 +469,7 @@ type jobExec struct {
|
||||
run db.CoreScheduledJobRun
|
||||
}
|
||||
|
||||
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)) *JobScheduler {
|
||||
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), pool *pgxpool.Pool) *JobScheduler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
@@ -427,6 +482,7 @@ func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger,
|
||||
queue: make(chan jobExec, 256),
|
||||
quit: make(chan struct{}),
|
||||
onEvent: onEvent,
|
||||
leader: newLeaderState(pool, leaderKeyScheduler),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,8 +507,13 @@ func (j *JobScheduler) run(ctx context.Context) {
|
||||
case exec := <-j.queue:
|
||||
j.execute(exec.job, exec.run)
|
||||
case <-ticker.C:
|
||||
j.scan()
|
||||
// Only the leader fires scheduled runs — manual/hook enqueues are
|
||||
// already scoped to whichever replica received the request.
|
||||
if j.leader.hold(ctx) {
|
||||
j.scan()
|
||||
}
|
||||
case <-j.quit:
|
||||
j.leader.stop(context.Background())
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
@@ -550,8 +611,13 @@ func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, sta
|
||||
}
|
||||
}
|
||||
|
||||
// deliver POSTs the job payload to its URL, signed like a webhook delivery.
|
||||
// deliver POSTs the job payload to its URL — or invokes the target function
|
||||
// when the job carries a function_id.
|
||||
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
|
||||
data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event": WebhookEventJobRun,
|
||||
"project_id": job.ProjectID.String(),
|
||||
@@ -560,8 +626,14 @@ func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run
|
||||
"run_id": run.ID.String(),
|
||||
"triggered_by": run.TriggeredBy,
|
||||
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"data": json.RawMessage(job.Payload),
|
||||
"data": data,
|
||||
})
|
||||
if job.FunctionID.Valid {
|
||||
if j.runFn == nil {
|
||||
return nil, fmt.Errorf("functions runtime not configured")
|
||||
}
|
||||
return j.runFn(ctx, job, run, data)
|
||||
}
|
||||
secret := ""
|
||||
if len(job.Secret) > 0 && j.enc != nil {
|
||||
if plain, err := j.enc.Decrypt(job.Secret); err == nil {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Leader election via Postgres advisory locks. The lock is session-scoped, so
|
||||
// the lease pins its pool connection for its entire lifetime — a handoff back
|
||||
// to the pool would silently drop it. A single-replica deployment simply
|
||||
// always holds the lock; there is no feature flag.
|
||||
//
|
||||
// Distinct keys per component so the job scheduler and the alert evaluator
|
||||
// elect independently.
|
||||
const (
|
||||
leaderKeyScheduler int64 = 728201
|
||||
leaderKeyAlerts int64 = 728202
|
||||
)
|
||||
|
||||
type leaderLease struct {
|
||||
conn *pgxpool.Conn
|
||||
key int64
|
||||
}
|
||||
|
||||
// acquireLeadership returns a lease when this process wins the lock, nil when
|
||||
// another replica holds it, or an error when the check itself failed.
|
||||
func acquireLeadership(ctx context.Context, pool *pgxpool.Pool, key int64) (*leaderLease, error) {
|
||||
conn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var got bool
|
||||
if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&got); err != nil {
|
||||
conn.Release()
|
||||
return nil, err
|
||||
}
|
||||
if !got {
|
||||
conn.Release()
|
||||
return nil, nil
|
||||
}
|
||||
return &leaderLease{conn: conn, key: key}, nil
|
||||
}
|
||||
|
||||
// alive pings the pinned connection — a dead conn means Postgres dropped the
|
||||
// session and released the lock, so leadership is lost even though the lease
|
||||
// object still exists.
|
||||
func (l *leaderLease) alive(ctx context.Context) bool {
|
||||
var one int
|
||||
return l.conn.QueryRow(ctx, "SELECT 1").Scan(&one) == nil
|
||||
}
|
||||
|
||||
func (l *leaderLease) release(ctx context.Context) {
|
||||
_, _ = l.conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", l.key)
|
||||
l.conn.Release()
|
||||
}
|
||||
|
||||
// leaderState tracks a lease inside a ticker loop — acquire when absent,
|
||||
// verify when held, drop and re-elect when the connection dies.
|
||||
type leaderState struct {
|
||||
pool *pgxpool.Pool
|
||||
key int64
|
||||
lease *leaderLease
|
||||
}
|
||||
|
||||
func newLeaderState(pool *pgxpool.Pool, key int64) *leaderState {
|
||||
return &leaderState{pool: pool, key: key}
|
||||
}
|
||||
|
||||
// hold reports whether this process currently leads. A nil pool means no
|
||||
// election configured (tests, embedded use) — always lead.
|
||||
func (l *leaderState) hold(ctx context.Context) bool {
|
||||
if l == nil || l.pool == nil {
|
||||
return true
|
||||
}
|
||||
if l.lease != nil {
|
||||
if l.lease.alive(ctx) {
|
||||
return true
|
||||
}
|
||||
l.lease.release(ctx)
|
||||
l.lease = nil
|
||||
}
|
||||
lease, err := acquireLeadership(ctx, l.pool, l.key)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
l.lease = lease
|
||||
return lease != nil
|
||||
}
|
||||
|
||||
func (l *leaderState) stop(ctx context.Context) {
|
||||
if l != nil && l.lease != nil {
|
||||
l.lease.release(ctx)
|
||||
l.lease = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
"github.com/tdvorak/primora/apps/backend/internal/config"
|
||||
)
|
||||
|
||||
// Mailer is transport-only: Resend when configured, plain SMTP otherwise.
|
||||
// Templates render (subject, body) from data; sends are logged to
|
||||
// core.email_log by the service layer so operators can see what went out.
|
||||
type Mailer struct {
|
||||
cfg config.Config
|
||||
resend *resend.Client
|
||||
@@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer {
|
||||
return &Mailer{cfg: cfg, resend: resendClient}
|
||||
}
|
||||
|
||||
func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error {
|
||||
subject := fmt.Sprintf("You were invited to %s on Primora", organizationName)
|
||||
text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n"
|
||||
// mailTemplate renders a subject + plaintext body from template data.
|
||||
type mailTemplate func(data map[string]string) (subject string, body string)
|
||||
|
||||
var mailTemplates = map[string]mailTemplate{
|
||||
"invitation": func(d map[string]string) (string, string) {
|
||||
subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"])
|
||||
body := fmt.Sprintf(
|
||||
"You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n",
|
||||
d["invite_url"],
|
||||
)
|
||||
return subject, body
|
||||
},
|
||||
}
|
||||
|
||||
func renderMailTemplate(template string, data map[string]string) (string, string, error) {
|
||||
tpl, ok := mailTemplates[template]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unknown email template %q", template)
|
||||
}
|
||||
subject, body := tpl(data)
|
||||
return subject, body, nil
|
||||
}
|
||||
|
||||
func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error {
|
||||
if m.resend != nil {
|
||||
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
||||
From: m.cfg.MailFrom,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderMailTemplateInvitation(t *testing.T) {
|
||||
subject, body, err := renderMailTemplate("invitation", map[string]string{
|
||||
"organization": "Acme Corp",
|
||||
"invite_url": "https://app.primora.dev/accept?token=abc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(subject, "Acme Corp") {
|
||||
t.Fatalf("subject missing organization: %q", subject)
|
||||
}
|
||||
if !strings.Contains(body, "https://app.primora.dev/accept?token=abc") {
|
||||
t.Fatalf("body missing invite url: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMailTemplateUnknown(t *testing.T) {
|
||||
if _, _, err := renderMailTemplate("does-not-exist", nil); err == nil {
|
||||
t.Fatal("expected error for unknown template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmail(t *testing.T) {
|
||||
if got := extractEmail("Primora <[email protected]>"); got != "[email protected]" {
|
||||
t.Fatalf("extractEmail = %q", got)
|
||||
}
|
||||
if got := extractEmail("[email protected]"); got != "[email protected]" {
|
||||
t.Fatalf("extractEmail bare = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,12 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -29,7 +32,7 @@ import (
|
||||
|
||||
type PlatformService struct {
|
||||
repo *repositories.CoreRepository
|
||||
store *storage.LocalStore
|
||||
store storage.Store
|
||||
mailer *Mailer
|
||||
publicURL string
|
||||
dbx *dbx.Client
|
||||
@@ -38,6 +41,13 @@ type PlatformService struct {
|
||||
enc *secrets.Encryptor
|
||||
dispatcher *WebhookDispatcher
|
||||
scheduler *JobScheduler
|
||||
alerts *alertEvaluator
|
||||
functions FunctionRunner
|
||||
logger *slog.Logger
|
||||
|
||||
// presenceMu guards presence: realtime subscriber counts per project.
|
||||
presenceMu sync.Mutex
|
||||
presence map[uuid.UUID]int
|
||||
}
|
||||
|
||||
type BootstrapInput struct {
|
||||
@@ -223,17 +233,84 @@ type InvitationSummary struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
|
||||
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc}
|
||||
func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *PlatformService {
|
||||
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions, logger: logger, presence: map[uuid.UUID]int{}}
|
||||
if enc != nil {
|
||||
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
||||
s.dispatcher.Start(context.Background())
|
||||
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent)
|
||||
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent, repo.Pool())
|
||||
s.scheduler.runFn = s.runJobFunction
|
||||
s.scheduler.Start(context.Background())
|
||||
s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool())
|
||||
s.alerts.Start(context.Background())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sendTemplatedEmail renders a registered template, sends it, and writes an
|
||||
// email_log row with the outcome — failed sends are still logged so the
|
||||
// dashboard shows them.
|
||||
func (s *PlatformService) sendTemplatedEmail(ctx context.Context, projectID *uuid.UUID, template, to string, data map[string]string) error {
|
||||
subject, body, err := renderMailTemplate(template, data)
|
||||
status, errText := "sent", ""
|
||||
if err == nil {
|
||||
if sendErr := s.mailer.Send(ctx, to, subject, body); sendErr != nil {
|
||||
status, errText, err = "failed", sendErr.Error(), sendErr
|
||||
}
|
||||
} else {
|
||||
status, errText = "failed", err.Error()
|
||||
}
|
||||
var pid pgtype.UUID
|
||||
if projectID != nil {
|
||||
pid = pgtype.UUID{Bytes: *projectID, Valid: true}
|
||||
}
|
||||
_, _ = s.repo.Queries().InsertEmailLog(ctx, db.InsertEmailLogParams{
|
||||
ProjectID: pid,
|
||||
Template: template,
|
||||
ToEmail: to,
|
||||
Subject: subject,
|
||||
Status: status,
|
||||
Error: errText,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type EmailLogSummary struct {
|
||||
ID string `json:"id"`
|
||||
Template string `json:"template"`
|
||||
ToEmail string `json:"to_email"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListEmailLog(ctx context.Context, actor *models.Actor, projectID uuid.UUID, limit int64) ([]EmailLogSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.repo.Queries().ListEmailLog(ctx, db.ListEmailLogParams{ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, Limit: int32(limit)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]EmailLogSummary, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = EmailLogSummary{
|
||||
ID: r.ID.String(),
|
||||
Template: r.Template,
|
||||
ToEmail: r.ToEmail,
|
||||
Subject: r.Subject,
|
||||
Status: r.Status,
|
||||
Error: r.Error,
|
||||
CreatedAt: r.CreatedAt.Time,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Close stops the webhook dispatcher and job scheduler. Idempotent.
|
||||
func (s *PlatformService) Close() {
|
||||
if s.dispatcher != nil {
|
||||
@@ -242,6 +319,9 @@ func (s *PlatformService) Close() {
|
||||
if s.scheduler != nil {
|
||||
s.scheduler.Stop()
|
||||
}
|
||||
if s.alerts != nil {
|
||||
s.alerts.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// publishEvent fans a domain event out to realtime SSE subscribers and to
|
||||
@@ -256,18 +336,118 @@ func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID,
|
||||
})
|
||||
}
|
||||
s.dispatchEvent(ctx, projectID, eventType, data)
|
||||
s.triggerFunctionsForEvent(ctx, projectID, eventType, data)
|
||||
}
|
||||
|
||||
// SubscribeRealtime registers an SSE consumer for project domain events.
|
||||
// SubscribeRealtime registers an SSE consumer for project domain events and
|
||||
// bumps the project's presence count.
|
||||
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.realtime.Subscribe(projectID.String()), nil
|
||||
ch := s.realtime.Subscribe(projectID.String())
|
||||
s.bumpPresence(projectID, 1)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
|
||||
s.realtime.Unsubscribe(projectID.String(), ch)
|
||||
s.bumpPresence(projectID, -1)
|
||||
}
|
||||
|
||||
// bumpPresence adjusts the online count and broadcasts presence.update to
|
||||
// realtime subscribers only — deliberately not through publishEvent, so
|
||||
// webhooks and `event_pattern: "*"` functions don't fire on connect noise.
|
||||
func (s *PlatformService) bumpPresence(projectID uuid.UUID, delta int) {
|
||||
s.presenceMu.Lock()
|
||||
n := s.presence[projectID] + delta
|
||||
if n <= 0 {
|
||||
delete(s.presence, projectID)
|
||||
n = 0
|
||||
} else {
|
||||
s.presence[projectID] = n
|
||||
}
|
||||
s.presenceMu.Unlock()
|
||||
s.realtime.Broadcast(projectID.String(), map[string]any{
|
||||
"type": "presence.update",
|
||||
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"data": map[string]any{"online": n},
|
||||
})
|
||||
}
|
||||
|
||||
// Presence returns the current realtime subscriber count for a project.
|
||||
func (s *PlatformService) Presence(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (int, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.presenceMu.Lock()
|
||||
defer s.presenceMu.Unlock()
|
||||
return s.presence[projectID], nil
|
||||
}
|
||||
|
||||
var customEventTypeRe = regexp.MustCompile(`^custom\.[a-z0-9][a-z0-9_.-]{0,62}$`)
|
||||
|
||||
// PublishProjectEvent lets a member emit a custom domain event — realtime
|
||||
// subscribers, matching webhooks, and event_pattern functions all fire via
|
||||
// the standard publishEvent fan-out. The custom.* prefix is enforced so
|
||||
// clients can't spoof system event types (document.created etc.).
|
||||
func (s *PlatformService) PublishProjectEvent(ctx context.Context, actor *models.Actor, projectID uuid.UUID, eventType string, data map[string]any) error {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !customEventTypeRe.MatchString(eventType) {
|
||||
return errors.New("invalid event type: must match custom.<name> (lowercase letters, digits, dots, dashes)")
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
s.publishEvent(ctx, projectID, eventType, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActorContext describes the caller's own scope — the self-discovery
|
||||
// endpoint for API keys, which cannot call /me. User actors get just the
|
||||
// type marker; their full view is PlatformSummary.
|
||||
type ActorContext struct {
|
||||
Actor string `json:"actor"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
KeyPrefix string `json:"key_prefix,omitempty"`
|
||||
Organization *ContextOrgInfo `json:"organization,omitempty"`
|
||||
Project *ContextRef `json:"project,omitempty"`
|
||||
}
|
||||
|
||||
type ContextOrgInfo struct {
|
||||
ContextRef
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
type ContextRef struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (s *PlatformService) ActorContext(ctx context.Context, actor *models.Actor) (ActorContext, error) {
|
||||
out := ActorContext{Actor: string(actor.Type)}
|
||||
if actor.Type != models.ActorTypeAPIKey {
|
||||
return out, nil
|
||||
}
|
||||
out.Scopes = actor.Scopes
|
||||
out.KeyPrefix = actor.APIKeyPrefix
|
||||
if actor.ProjectID == nil {
|
||||
return out, nil
|
||||
}
|
||||
project, err := s.repo.Queries().GetProjectByID(ctx, *actor.ProjectID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Project = &ContextRef{ID: project.ID, Slug: project.Slug, Name: project.Name}
|
||||
org, err := s.repo.Queries().GetOrganizationByID(ctx, project.OrganizationID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Organization = &ContextOrgInfo{ContextRef: ContextRef{ID: org.ID, Slug: org.Slug, Name: org.Name}}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
|
||||
@@ -907,7 +1087,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac
|
||||
if err != nil {
|
||||
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
|
||||
}
|
||||
if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil {
|
||||
var inviteProject *uuid.UUID
|
||||
if projectUUID.Valid {
|
||||
id := uuid.UUID(projectUUID.Bytes)
|
||||
inviteProject = &id
|
||||
}
|
||||
if err := s.sendTemplatedEmail(ctx, inviteProject, "invitation", invitation.Email, map[string]string{
|
||||
"organization": "Primora",
|
||||
"invite_url": inviteURL,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
@@ -1336,6 +1524,64 @@ func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bu
|
||||
return object, file, nil
|
||||
}
|
||||
|
||||
// ErrPresignUnsupported — the configured storage driver can't mint signed
|
||||
// URLs (only the S3 driver can). Handlers map it to 400, not 500.
|
||||
var ErrPresignUnsupported = errors.New("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
|
||||
|
||||
// InputError marks a service-layer failure caused by caller input — the
|
||||
// HTTP layer maps it to 400 instead of 500.
|
||||
type InputError struct{ msg string }
|
||||
|
||||
func (e *InputError) Error() string { return e.msg }
|
||||
|
||||
func NewInputError(msg string) error { return &InputError{msg: msg} }
|
||||
|
||||
func inputErrorf(format string, args ...any) error {
|
||||
return &InputError{msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// PresignObject mints a short-lived direct-to-storage URL for a single
|
||||
// object. Requires the s3 storage driver — the local store has no external
|
||||
// URL surface to sign against.
|
||||
func (s *PlatformService) PresignObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, op string, ttlSeconds int) (*storage.PresignedURL, error) {
|
||||
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
method := ""
|
||||
switch op {
|
||||
case "download", "":
|
||||
method = http.MethodGet
|
||||
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !objectExists(ctx, s, bucketID, objectKey) {
|
||||
return nil, pgx.ErrNoRows
|
||||
}
|
||||
case "upload":
|
||||
method = http.MethodPut
|
||||
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid op %q — download or upload", op)
|
||||
}
|
||||
p, ok := s.store.(storage.Presigner)
|
||||
if !ok {
|
||||
return nil, ErrPresignUnsupported
|
||||
}
|
||||
ttl := time.Duration(ttlSeconds) * time.Second
|
||||
return p.Presign(ctx, bucketID.String(), objectKey, method, ttl)
|
||||
}
|
||||
|
||||
func objectExists(ctx context.Context, s *PlatformService, bucketID uuid.UUID, key string) bool {
|
||||
_, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
|
||||
BucketID: bucketID,
|
||||
ObjectKey: key,
|
||||
})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
|
||||
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/secrets"
|
||||
)
|
||||
|
||||
// Project secrets are the server-side counterpart of the CLI's local vault:
|
||||
// AES-256-GCM at rest, metadata-only listings, audited reveal. Job payloads
|
||||
// reference them as secret://NAME — resolved at delivery, never stored in
|
||||
// plaintext config.
|
||||
|
||||
var projectSecretNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
type ProjectSecretSummary struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SetProjectSecretInput struct {
|
||||
Name string `json:"name"` // from the path param, not the body
|
||||
Value string `json:"value" validate:"required"`
|
||||
URL *string `json:"url"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
type RevealProjectSecretResponse struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func toProjectSecretSummary(row db.ListProjectSecretsRow) ProjectSecretSummary {
|
||||
return ProjectSecretSummary{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
URL: row.Url,
|
||||
Notes: row.Notes,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListProjectSecrets(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ProjectSecretSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.repo.Queries().ListProjectSecrets(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ProjectSecretSummary, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, toProjectSecretSummary(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) SetProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input SetProjectSecretInput, requestID string) (ProjectSecretSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return ProjectSecretSummary{}, err
|
||||
}
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if !projectSecretNameRe.MatchString(name) {
|
||||
return ProjectSecretSummary{}, fmt.Errorf("invalid secret name — use env-var-safe names [A-Za-z_][A-Za-z0-9_]*")
|
||||
}
|
||||
if s.enc == nil {
|
||||
return ProjectSecretSummary{}, fmt.Errorf("encryption is not configured on this deployment")
|
||||
}
|
||||
ciphertext, err := s.enc.Encrypt([]byte(input.Value))
|
||||
if err != nil {
|
||||
return ProjectSecretSummary{}, err
|
||||
}
|
||||
// Merge url/notes over the existing row when the caller omits them —
|
||||
// `secrets set NAME` should not wipe metadata it never saw.
|
||||
var url, notes string
|
||||
if existing, err := s.repo.Queries().GetProjectSecret(ctx, db.GetProjectSecretParams{ProjectID: projectID, Name: name}); err == nil {
|
||||
url, notes = existing.Url, existing.Notes
|
||||
} else if err != pgx.ErrNoRows {
|
||||
return ProjectSecretSummary{}, err
|
||||
}
|
||||
if input.URL != nil {
|
||||
url = strings.TrimSpace(*input.URL)
|
||||
}
|
||||
if input.Notes != nil {
|
||||
notes = strings.TrimSpace(*input.Notes)
|
||||
}
|
||||
row, err := s.repo.Queries().UpsertProjectSecret(ctx, db.UpsertProjectSecretParams{
|
||||
ProjectID: projectID,
|
||||
Name: name,
|
||||
Ciphertext: ciphertext,
|
||||
Url: url,
|
||||
Notes: notes,
|
||||
})
|
||||
if err != nil {
|
||||
return ProjectSecretSummary{}, err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.set", "project_secret", row.ID.String(), map[string]any{
|
||||
"name": row.Name,
|
||||
}))
|
||||
}
|
||||
return ProjectSecretSummary{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
URL: row.Url,
|
||||
Notes: row.Notes,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) DeleteProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name string, requestID string) error {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
row, err := s.repo.Queries().DeleteProjectSecret(ctx, db.DeleteProjectSecretParams{ProjectID: projectID, Name: name})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.deleted", "project_secret", row.String(), map[string]any{
|
||||
"name": name,
|
||||
}))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reveal decrypts a single secret. Deliberately loud: audit-logged every time.
|
||||
func (s *PlatformService) RevealProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name string, requestID string) (RevealProjectSecretResponse, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return RevealProjectSecretResponse{}, err
|
||||
}
|
||||
row, err := s.repo.Queries().GetProjectSecret(ctx, db.GetProjectSecretParams{ProjectID: projectID, Name: name})
|
||||
if err != nil {
|
||||
return RevealProjectSecretResponse{}, err
|
||||
}
|
||||
plain, err := s.enc.Decrypt(row.Ciphertext)
|
||||
if err != nil {
|
||||
return RevealProjectSecretResponse{}, err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.revealed", "project_secret", row.ID.String(), map[string]any{
|
||||
"name": row.Name,
|
||||
}))
|
||||
}
|
||||
return RevealProjectSecretResponse{Name: row.Name, Value: string(plain)}, nil
|
||||
}
|
||||
|
||||
// resolveSecretRefs replaces "secret://NAME" string values in a payload with
|
||||
// decrypted values. Names that do not resolve fail loudly — a literal
|
||||
// secret:// string reaching the endpoint is worse than a failed run.
|
||||
// Package-level so the JobScheduler can reuse it through its narrow repo iface.
|
||||
func resolveSecretRefs(
|
||||
ctx context.Context,
|
||||
list func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error),
|
||||
enc *secrets.Encryptor,
|
||||
projectID uuid.UUID,
|
||||
payload json.RawMessage,
|
||||
) (json.RawMessage, error) {
|
||||
if len(payload) == 0 || !strings.Contains(string(payload), "secret://") {
|
||||
return payload, nil
|
||||
}
|
||||
if enc == nil {
|
||||
return nil, fmt.Errorf("payload references project secrets but encryption is not configured on this deployment")
|
||||
}
|
||||
rows, err := list(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make(map[string]string, len(rows))
|
||||
for _, row := range rows {
|
||||
plain, err := enc.Decrypt(row.Ciphertext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("project secret %q failed to decrypt", row.Name)
|
||||
}
|
||||
values[row.Name] = string(plain)
|
||||
}
|
||||
var doc any
|
||||
if err := json.Unmarshal(payload, &doc); err != nil {
|
||||
return nil, fmt.Errorf("payload is not valid JSON: %w", err)
|
||||
}
|
||||
var missing []string
|
||||
resolved := walkSecretRefs(doc, values, &missing)
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("unresolved secret references: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
out, err := json.Marshal(resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func walkSecretRefs(node any, values map[string]string, missing *[]string) any {
|
||||
switch v := node.(type) {
|
||||
case string:
|
||||
if strings.HasPrefix(v, "secret://") {
|
||||
name := strings.TrimPrefix(v, "secret://")
|
||||
if resolved, ok := values[name]; ok {
|
||||
return resolved
|
||||
}
|
||||
*missing = append(*missing, name)
|
||||
return v
|
||||
}
|
||||
return v
|
||||
case map[string]any:
|
||||
for k, item := range v {
|
||||
v[k] = walkSecretRefs(item, values, missing)
|
||||
}
|
||||
return v
|
||||
case []any:
|
||||
for i, item := range v {
|
||||
v[i] = walkSecretRefs(item, values, missing)
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return node
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/secrets"
|
||||
)
|
||||
|
||||
func testEncryptor(t *testing.T) *secrets.Encryptor {
|
||||
t.Helper()
|
||||
key := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
enc, err := secrets.NewEncryptor(key)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEncryptor: %v", err)
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
func secretLister(t *testing.T, enc *secrets.Encryptor, kv map[string]string) func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error) {
|
||||
t.Helper()
|
||||
return func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error) {
|
||||
rows := make([]db.ListProjectSecretValuesRow, 0, len(kv))
|
||||
for name, value := range kv {
|
||||
ct, err := enc.Encrypt([]byte(value))
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt %s: %v", name, err)
|
||||
}
|
||||
rows = append(rows, db.ListProjectSecretValuesRow{Name: name, Ciphertext: ct})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSecretRefs(t *testing.T) {
|
||||
enc := testEncryptor(t)
|
||||
list := secretLister(t, enc, map[string]string{
|
||||
"API_KEY": "sk_live_99",
|
||||
"RECURSIVE": "secret://API_KEY", // must not resolve twice
|
||||
})
|
||||
pid := uuid.New()
|
||||
|
||||
t.Run("replaces nested and array refs", func(t *testing.T) {
|
||||
payload := json.RawMessage(`{
|
||||
"token": "secret://API_KEY",
|
||||
"nested": {"deep": ["a", "secret://API_KEY", {"x": "secret://API_KEY"}]},
|
||||
"plain": "not a secret",
|
||||
"num": 42
|
||||
}`)
|
||||
out, err := resolveSecretRefs(context.Background(), list, enc, pid, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out, &doc); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if doc["token"] != "sk_live_99" {
|
||||
t.Errorf("token = %v", doc["token"])
|
||||
}
|
||||
deep := doc["nested"].(map[string]any)["deep"].([]any)
|
||||
if deep[1] != "sk_live_99" || deep[2].(map[string]any)["x"] != "sk_live_99" {
|
||||
t.Errorf("nested refs not resolved: %v", deep)
|
||||
}
|
||||
if doc["plain"] != "not a secret" || doc["num"] != float64(42) {
|
||||
t.Errorf("non-ref values changed: %v", doc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing ref fails the job", func(t *testing.T) {
|
||||
_, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"secret://NOPE"}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "NOPE") {
|
||||
t.Fatalf("expected unresolved error naming NOPE, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("embedded ref in a larger string is not resolved", func(t *testing.T) {
|
||||
out, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"prefix secret://API_KEY suffix"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "secret://API_KEY") {
|
||||
t.Fatalf("embedded ref must pass through unchanged: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resolved value is not re-scanned", func(t *testing.T) {
|
||||
out, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"secret://RECURSIVE"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
_ = json.Unmarshal(out, &doc)
|
||||
if doc["a"] != "secret://API_KEY" {
|
||||
t.Fatalf("single-pass resolution expected, got %v", doc["a"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON fails loudly when a ref is present", func(t *testing.T) {
|
||||
_, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{secret://API_KEY`))
|
||||
if err == nil {
|
||||
t.Fatal("expected JSON error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("payloads without refs pass through untouched", func(t *testing.T) {
|
||||
payload := json.RawMessage(`{"a":1}`)
|
||||
out, err := resolveSecretRefs(context.Background(), list, enc, pid, payload)
|
||||
if err != nil || string(out) != string(payload) {
|
||||
t.Fatalf("passthrough failed: %v %s", err, out)
|
||||
}
|
||||
// Nil encryptor must fail loudly — a literal secret:// reaching the
|
||||
// endpoint is worse than a failed run.
|
||||
_, err = resolveSecretRefs(context.Background(), list, nil, pid, json.RawMessage(`{"a":"secret://API_KEY"}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when encryption is not configured")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -31,6 +32,8 @@ const (
|
||||
WebhookEventIssueCreated = "issue.created"
|
||||
WebhookEventDeployMarker = "deploy.marker"
|
||||
WebhookEventTest = "webhook.test"
|
||||
WebhookEventAlertFired = "alert.fired"
|
||||
WebhookEventAlertResolved = "alert.resolved"
|
||||
WebhookEventDocumentCreated = "document.created"
|
||||
WebhookEventDocumentUpdated = "document.updated"
|
||||
WebhookEventDocumentDeleted = "document.deleted"
|
||||
@@ -43,6 +46,9 @@ var webhookEventTypes = map[string]bool{
|
||||
WebhookEventIssueCreated: true,
|
||||
WebhookEventDeployMarker: true,
|
||||
WebhookEventTest: true,
|
||||
WebhookEventAlertFired: true,
|
||||
WebhookEventAlertResolved: true,
|
||||
WebhookEventInboundReceived: true,
|
||||
WebhookEventJobRun: true,
|
||||
WebhookEventDocumentCreated: true,
|
||||
WebhookEventDocumentUpdated: true,
|
||||
@@ -160,7 +166,7 @@ func normalizeWebhookURL(raw string) (string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
u, err := url.Parse(trimmed)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", errors.New("webhook url must be an https URL")
|
||||
return "", inputErrorf("webhook url must be an https URL")
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return trimmed, nil
|
||||
@@ -168,17 +174,35 @@ func normalizeWebhookURL(raw string) (string, error) {
|
||||
if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) {
|
||||
return trimmed, nil
|
||||
}
|
||||
return "", errors.New("webhook url must be https (http allowed only for private/self-hosted targets)")
|
||||
return "", inputErrorf("webhook url must be https (http allowed only for private/self-hosted targets)")
|
||||
}
|
||||
|
||||
var decimalOrHexIPRe = regexp.MustCompile(`^(0x[0-9a-f]+|[0-9]+)$`)
|
||||
|
||||
// metadataAddrs are link-local addresses that serve cloud instance
|
||||
// metadata — "private" by range but a privileged SSRF target.
|
||||
var metadataAddrs = map[string]bool{
|
||||
"169.254.169.254": true, // AWS/GCP/Azure
|
||||
"fd00:ec2::254": true, // AWS IPv6 metadata
|
||||
}
|
||||
|
||||
func isPrivateWebhookHost(host string) bool {
|
||||
h := strings.ToLower(strings.Trim(host, "[]"))
|
||||
if metadataAddrs[h] {
|
||||
return false
|
||||
}
|
||||
if h == "localhost" || h == "host.docker.internal" ||
|
||||
strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(h, ".") && !strings.Contains(h, ":") {
|
||||
return true // bare hostname — docker service name, unqualified LAN host
|
||||
// Bare hostname — docker service name, unqualified LAN host. A
|
||||
// pure decimal/hex integer is never a service name: it's a
|
||||
// non-canonical IP encoding (2130706433 = 127.0.0.1, 0x7f000001).
|
||||
if decimalOrHexIPRe.MatchString(h) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(h)
|
||||
if ip == nil {
|
||||
@@ -187,16 +211,40 @@ func isPrivateWebhookHost(host string) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
|
||||
}
|
||||
|
||||
// webhookEventNamespaces are the valid `<ns>.*` subscription prefixes —
|
||||
// derived from the known system event types.
|
||||
func webhookEventNamespaces() map[string]bool {
|
||||
ns := map[string]bool{}
|
||||
for t := range webhookEventTypes {
|
||||
if i := strings.Index(t, "."); i > 0 {
|
||||
ns[t[:i]] = true
|
||||
}
|
||||
}
|
||||
return ns
|
||||
}
|
||||
|
||||
// validateWebhookEvents accepts known system types, `*`, `<ns>.*` namespace
|
||||
// patterns, and `custom.*` wildcard patterns — the same grammar functions
|
||||
// use for event_pattern. Matching happens via SQL LIKE at dispatch.
|
||||
func validateWebhookEvents(events []string) ([]string, error) {
|
||||
out := make([]string, 0, len(events))
|
||||
seen := map[string]bool{}
|
||||
namespaces := webhookEventNamespaces()
|
||||
for _, e := range events {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if !webhookEventTypes[e] {
|
||||
return nil, fmt.Errorf("unknown event type %q", e)
|
||||
valid := webhookEventTypes[e] || e == "*"
|
||||
if !valid && strings.HasSuffix(e, ".*") {
|
||||
valid = namespaces[strings.TrimSuffix(e, ".*")]
|
||||
}
|
||||
if !valid && strings.HasPrefix(e, "custom.") {
|
||||
// `*` isn't in the type grammar — probe it as a literal.
|
||||
valid = customEventTypeRe.MatchString(strings.ReplaceAll(e, "*", "x"))
|
||||
}
|
||||
if !valid {
|
||||
return nil, inputErrorf("invalid event type %q — use a known type, <namespace>.*, custom.<pattern>, or *", e)
|
||||
}
|
||||
if !seen[e] {
|
||||
seen[e] = true
|
||||
|
||||
@@ -65,7 +65,7 @@ func (s *LocalStore) Put(ctx context.Context, bucketID, objectKey string, reader
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *LocalStore) Open(bucketID, objectKey string) (*os.File, string, error) {
|
||||
func (s *LocalStore) Open(bucketID, objectKey string) (io.ReadCloser, string, error) {
|
||||
cleanKey, err := sanitizeObjectKey(objectKey)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// S3Config configures an S3-compatible object store (AWS S3, MinIO,
|
||||
// Cloudflare R2, Garage, …). Endpoint may be empty for AWS, in which case
|
||||
// the standard regional endpoint is derived from Region + Bucket.
|
||||
type S3Config struct {
|
||||
Endpoint string // e.g. http://localhost:9000 or https://s3.eu-central-1.amazonaws.com
|
||||
Region string
|
||||
Bucket string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
Prefix string // optional key prefix, e.g. "primora/"
|
||||
PathStyle bool // bucket/key path style (MinIO, Garage) vs virtual-hosted
|
||||
// PublicEndpoint rewrites presigned-URL hosts for clients that can't
|
||||
// reach Endpoint (e.g. minio:9000 inside compose). Signature binds the
|
||||
// public host — the URL is only valid there.
|
||||
PublicEndpoint string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// S3Store implements Store against an S3-compatible API using stdlib-only
|
||||
// SigV4 signing — no AWS SDK dependency. Payloads are sent with
|
||||
// x-amz-content-sha256=UNSIGNED-PAYLOAD so bodies stream without buffering;
|
||||
// the SHA-256 for PutResult is computed on a hashing tee instead.
|
||||
// jarvis: ceiling UNSIGNED-PAYLOAD needs TLS on AWS proper; over plain
|
||||
// http:// it relies on the server accepting it (MinIO/Garage/LocalStack do).
|
||||
// Upgrade to aws-chunked streaming signatures if a backend rejects it.
|
||||
type S3Store struct {
|
||||
cfg S3Config
|
||||
client *http.Client
|
||||
now func() time.Time // injectable for tests
|
||||
}
|
||||
|
||||
func NewS3Store(cfg S3Config) (*S3Store, error) {
|
||||
if cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
|
||||
return nil, fmt.Errorf("s3 storage requires S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY")
|
||||
}
|
||||
if cfg.Region == "" {
|
||||
cfg.Region = "us-east-1"
|
||||
}
|
||||
if cfg.Endpoint == "" {
|
||||
cfg.Endpoint = fmt.Sprintf("https://s3.%s.amazonaws.com", cfg.Region)
|
||||
}
|
||||
client := cfg.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 5 * time.Minute}
|
||||
}
|
||||
return &S3Store{cfg: cfg, client: client, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func escapeKey(key string) string {
|
||||
parts := strings.Split(key, "/")
|
||||
for i, p := range parts {
|
||||
parts[i] = url.PathEscape(p)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func (s *S3Store) Put(ctx context.Context, bucketID, objectKey string, reader io.Reader) (PutResult, error) {
|
||||
key, err := s.objectKey(bucketID, objectKey)
|
||||
if err != nil {
|
||||
return PutResult{}, err
|
||||
}
|
||||
|
||||
// S3 requires a Content-Length — chunked transfer encoding is rejected.
|
||||
// Seekable readers (uploaded *os.File) give the length for free;
|
||||
// anything else is spooled to a temp file, same cost the local driver
|
||||
// pays on every write anyway.
|
||||
body, length, cleanup, err := bodyWithLength(reader)
|
||||
if err != nil {
|
||||
return PutResult{}, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
hasher := sha256.New()
|
||||
counter := &countingReader{r: io.TeeReader(body, hasher)}
|
||||
|
||||
req, err := s.signedRequest(ctx, http.MethodPut, key, counter)
|
||||
if err != nil {
|
||||
return PutResult{}, err
|
||||
}
|
||||
req.ContentLength = length
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return PutResult{}, fmt.Errorf("s3 put: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return PutResult{}, fmt.Errorf("s3 put: %s: %s", resp.Status, readErrBody(resp))
|
||||
}
|
||||
return PutResult{
|
||||
Path: fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, key),
|
||||
SizeBytes: counter.n,
|
||||
SHA256Digest: hex.EncodeToString(hasher.Sum(nil)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// bodyWithLength returns a reader plus its total length. The cleanup func
|
||||
// removes any temp file it created.
|
||||
func bodyWithLength(r io.Reader) (io.Reader, int64, func(), error) {
|
||||
nop := func() {}
|
||||
if seeker, ok := r.(io.Seeker); ok {
|
||||
size, err := seeker.Seek(0, io.SeekEnd)
|
||||
if err != nil {
|
||||
return nil, 0, nil, fmt.Errorf("seek object body: %w", err)
|
||||
}
|
||||
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, 0, nil, fmt.Errorf("rewind object body: %w", err)
|
||||
}
|
||||
return r, size, nop, nil
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "primora-s3-put-*")
|
||||
if err != nil {
|
||||
return nil, 0, nil, fmt.Errorf("spool object body: %w", err)
|
||||
}
|
||||
size, err := io.Copy(tmp, r)
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
return nil, 0, nil, fmt.Errorf("spool object body: %w", err)
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
cleanup := func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
}
|
||||
return tmp, size, cleanup, nil
|
||||
}
|
||||
|
||||
type countingReader struct {
|
||||
r io.Reader
|
||||
n int64
|
||||
}
|
||||
|
||||
func (c *countingReader) Read(p []byte) (int, error) {
|
||||
n, err := c.r.Read(p)
|
||||
c.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *S3Store) Open(bucketID, objectKey string) (io.ReadCloser, string, error) {
|
||||
key, err := s.objectKey(bucketID, objectKey)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req, err := s.signedRequest(context.Background(), http.MethodGet, key, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("s3 get: %w", err)
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
defer resp.Body.Close()
|
||||
return nil, "", fmt.Errorf("s3 get: %s: %s", resp.Status, readErrBody(resp))
|
||||
}
|
||||
return resp.Body, fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, key), nil
|
||||
}
|
||||
|
||||
func (s *S3Store) Delete(bucketID, objectKey string) error {
|
||||
key, err := s.objectKey(bucketID, objectKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := s.signedRequest(context.Background(), http.MethodDelete, key, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("s3 delete: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// S3 delete is idempotent: 204 on success, 404 is fine too.
|
||||
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
|
||||
return fmt.Errorf("s3 delete: %s: %s", resp.Status, readErrBody(resp))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Store) MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error) {
|
||||
src, err := s.objectKey(sourceBucketID, fromKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dst, err := s.objectKey(destinationBucketID, toKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// x-amz-copy-source must be set before signing — strict S3
|
||||
// implementations require all x-amz-* headers to be signed.
|
||||
key := dst
|
||||
u := strings.TrimSuffix(s.cfg.Endpoint, "/") + "/" + s.cfg.Bucket + "/" + escapeKey(key)
|
||||
if !s.cfg.PathStyle {
|
||||
parsed, perr := url.Parse(strings.TrimSuffix(s.cfg.Endpoint, "/"))
|
||||
if perr != nil {
|
||||
return "", perr
|
||||
}
|
||||
parsed.Host = s.cfg.Bucket + "." + parsed.Host
|
||||
parsed.Path = "/" + escapeKey(key)
|
||||
u = parsed.String()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, u, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
|
||||
req.Header.Set("x-amz-copy-source", "/"+s.cfg.Bucket+"/"+escapeKey(src))
|
||||
s.sign(req)
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("s3 copy: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return "", fmt.Errorf("s3 copy: %s: %s", resp.Status, readErrBody(resp))
|
||||
}
|
||||
if err := s.Delete(sourceBucketID, fromKey); err != nil {
|
||||
return "", fmt.Errorf("s3 move: delete source: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, dst), nil
|
||||
}
|
||||
|
||||
// DeleteBucket lists every object under the bucket prefix and deletes them
|
||||
// one by one — DeleteObjects batching is an optimization not worth the code
|
||||
// at this scale.
|
||||
func (s *S3Store) DeleteBucket(bucketID string) error {
|
||||
prefix := path.Join(s.cfg.Prefix, bucketID) + "/"
|
||||
continuation := ""
|
||||
for {
|
||||
keys, next, err := s.listPage(prefix, continuation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
req, err := s.signedRequest(context.Background(), http.MethodDelete, k, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("s3 delete %s: %w", k, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
|
||||
return fmt.Errorf("s3 delete %s: %s", k, resp.Status)
|
||||
}
|
||||
}
|
||||
if next == "" {
|
||||
return nil
|
||||
}
|
||||
continuation = next
|
||||
}
|
||||
}
|
||||
|
||||
// Presign mints a SigV4 query-signed URL for a single GET/PUT on the
|
||||
// object — clients talk to S3 directly instead of proxying bytes through
|
||||
// the backend. TTL is capped at one hour.
|
||||
func (s *S3Store) Presign(ctx context.Context, bucketID, objectKey, method string, ttl time.Duration) (*PresignedURL, error) {
|
||||
if method != http.MethodGet && method != http.MethodPut {
|
||||
return nil, fmt.Errorf("s3 presign: unsupported method %q", method)
|
||||
}
|
||||
key, err := s.objectKey(bucketID, objectKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 15 * time.Minute
|
||||
}
|
||||
if ttl > time.Hour {
|
||||
ttl = time.Hour
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
dateStamp := now.Format("20060102")
|
||||
scope := fmt.Sprintf("%s/%s/s3/aws4_request", dateStamp, s.cfg.Region)
|
||||
|
||||
// Build the unsigned URL — same path/vhost logic as signedRequest, but
|
||||
// on the client-facing endpoint when one is configured.
|
||||
var base *url.URL
|
||||
endpoint := strings.TrimSuffix(s.cfg.PublicEndpoint, "/")
|
||||
if endpoint == "" {
|
||||
endpoint = strings.TrimSuffix(s.cfg.Endpoint, "/")
|
||||
}
|
||||
if s.cfg.PathStyle {
|
||||
u, err := url.Parse(endpoint + "/" + s.cfg.Bucket + "/" + escapeKey(key))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("s3 presign url: %w", err)
|
||||
}
|
||||
base = u
|
||||
} else {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("s3 presign url: %w", err)
|
||||
}
|
||||
u.Host = s.cfg.Bucket + "." + u.Host
|
||||
u.Path = "/" + escapeKey(key)
|
||||
base = u
|
||||
}
|
||||
|
||||
q := base.Query()
|
||||
q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
|
||||
q.Set("X-Amz-Credential", s.cfg.AccessKeyID+"/"+scope)
|
||||
q.Set("X-Amz-Date", amzDate)
|
||||
q.Set("X-Amz-Expires", fmt.Sprintf("%d", int64(ttl.Seconds())))
|
||||
q.Set("X-Amz-SignedHeaders", "host")
|
||||
|
||||
canonicalURI := base.EscapedPath()
|
||||
if canonicalURI == "" {
|
||||
canonicalURI = "/"
|
||||
}
|
||||
canonicalRequest := strings.Join([]string{
|
||||
method,
|
||||
canonicalURI,
|
||||
canonicalQuery(q),
|
||||
"host:" + base.Host + "\n",
|
||||
"host",
|
||||
"UNSIGNED-PAYLOAD",
|
||||
}, "\n")
|
||||
stringToSign := strings.Join([]string{
|
||||
"AWS4-HMAC-SHA256",
|
||||
amzDate,
|
||||
scope,
|
||||
hexSHA256(canonicalRequest),
|
||||
}, "\n")
|
||||
|
||||
kDate := hmacSHA256([]byte("AWS4"+s.cfg.SecretAccessKey), dateStamp)
|
||||
kRegion := hmacSHA256(kDate, s.cfg.Region)
|
||||
kService := hmacSHA256(kRegion, "s3")
|
||||
kSigning := hmacSHA256(kService, "aws4_request")
|
||||
q.Set("X-Amz-Signature", hex.EncodeToString(hmacSHA256(kSigning, stringToSign)))
|
||||
base.RawQuery = q.Encode()
|
||||
|
||||
return &PresignedURL{URL: base.String(), Method: method, ExpiresAt: now.Add(ttl)}, nil
|
||||
}
|
||||
|
||||
type listResult struct {
|
||||
Contents []struct{ Key string `xml:"Key"` } `xml:"Contents"`
|
||||
NextContinuationToken string `xml:"NextContinuationToken"`
|
||||
IsTruncated bool `xml:"IsTruncated"`
|
||||
}
|
||||
|
||||
func (s *S3Store) listPage(prefix, continuation string) ([]string, string, error) {
|
||||
query := url.Values{
|
||||
"list-type": {"2"},
|
||||
"prefix": {prefix},
|
||||
"max-keys": {"1000"},
|
||||
}
|
||||
if continuation != "" {
|
||||
query.Set("continuation-token", continuation)
|
||||
}
|
||||
req, err := s.signedRequestWithQuery(context.Background(), http.MethodGet, "", query)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("s3 list: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, "", fmt.Errorf("s3 list: %s: %s", resp.Status, readErrBody(resp))
|
||||
}
|
||||
var out listResult
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, "", fmt.Errorf("s3 list decode: %w", err)
|
||||
}
|
||||
keys := make([]string, len(out.Contents))
|
||||
for i, c := range out.Contents {
|
||||
keys[i] = c.Key
|
||||
}
|
||||
next := out.NextContinuationToken
|
||||
if next == "" && out.IsTruncated && len(keys) > 0 {
|
||||
next = keys[len(keys)-1] // S3 uses last key as marker when no token
|
||||
}
|
||||
return keys, next, nil
|
||||
}
|
||||
|
||||
// --- SigV4 signing -------------------------------------------------------
|
||||
|
||||
func (s *S3Store) signedRequest(ctx context.Context, method, key string, body io.Reader) (*http.Request, error) {
|
||||
return s.signed(ctx, method, key, nil, body)
|
||||
}
|
||||
|
||||
func (s *S3Store) signedRequestWithQuery(ctx context.Context, method, key string, query url.Values) (*http.Request, error) {
|
||||
return s.signed(ctx, method, key, query, nil)
|
||||
}
|
||||
|
||||
func (s *S3Store) signed(ctx context.Context, method, key string, query url.Values, body io.Reader) (*http.Request, error) {
|
||||
var reqURL string
|
||||
endpoint := strings.TrimSuffix(s.cfg.Endpoint, "/")
|
||||
if s.cfg.PathStyle {
|
||||
reqURL = endpoint + "/" + s.cfg.Bucket
|
||||
if key != "" {
|
||||
reqURL += "/" + escapeKey(key)
|
||||
}
|
||||
} else {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("s3 endpoint: %w", err)
|
||||
}
|
||||
u.Host = s.cfg.Bucket + "." + u.Host
|
||||
u.Path = "/" + escapeKey(key)
|
||||
reqURL = u.String()
|
||||
}
|
||||
if len(query) > 0 {
|
||||
reqURL += "?" + query.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
|
||||
s.sign(req)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// sign applies AWS Signature Version 4 to req in place.
|
||||
func (s *S3Store) sign(req *http.Request) {
|
||||
now := s.now().UTC()
|
||||
amzDate := now.Format("20060102T150405Z")
|
||||
dateStamp := now.Format("20060102")
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
|
||||
// Canonical request: method, path, sorted query, headers, signed list,
|
||||
// payload hash. Signed headers are host + every x-amz-* header present —
|
||||
// required for x-amz-copy-source on CopyObject and future metadata
|
||||
// headers.
|
||||
signedHeaders := []string{"host"}
|
||||
for h := range req.Header {
|
||||
lh := strings.ToLower(h)
|
||||
if strings.HasPrefix(lh, "x-amz-") {
|
||||
signedHeaders = append(signedHeaders, lh)
|
||||
}
|
||||
}
|
||||
sort.Strings(signedHeaders)
|
||||
var canonicalHeaders strings.Builder
|
||||
for _, h := range signedHeaders {
|
||||
v := req.Host
|
||||
if h != "host" {
|
||||
v = strings.TrimSpace(req.Header.Get(h))
|
||||
}
|
||||
canonicalHeaders.WriteString(h + ":" + v + "\n")
|
||||
}
|
||||
canonicalURI := req.URL.EscapedPath()
|
||||
if canonicalURI == "" {
|
||||
canonicalURI = "/"
|
||||
}
|
||||
canonicalRequest := strings.Join([]string{
|
||||
req.Method,
|
||||
canonicalURI,
|
||||
canonicalQuery(req.URL.Query()),
|
||||
canonicalHeaders.String(),
|
||||
strings.Join(signedHeaders, ";"),
|
||||
"UNSIGNED-PAYLOAD",
|
||||
}, "\n")
|
||||
|
||||
scope := fmt.Sprintf("%s/%s/s3/aws4_request", dateStamp, s.cfg.Region)
|
||||
stringToSign := strings.Join([]string{
|
||||
"AWS4-HMAC-SHA256",
|
||||
amzDate,
|
||||
scope,
|
||||
hexSHA256(canonicalRequest),
|
||||
}, "\n")
|
||||
|
||||
kDate := hmacSHA256([]byte("AWS4"+s.cfg.SecretAccessKey), dateStamp)
|
||||
kRegion := hmacSHA256(kDate, s.cfg.Region)
|
||||
kService := hmacSHA256(kRegion, "s3")
|
||||
kSigning := hmacSHA256(kService, "aws4_request")
|
||||
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf(
|
||||
"AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||
s.cfg.AccessKeyID, scope, strings.Join(signedHeaders, ";"), signature))
|
||||
}
|
||||
|
||||
// canonicalQuery re-encodes the query per SigV4: sorted keys, each name and
|
||||
// value URI-encoded per RFC 3986 (url.Values.Encode's "+" for space is not
|
||||
// acceptable here).
|
||||
func canonicalQuery(q url.Values) string {
|
||||
type pair struct{ k, v string }
|
||||
pairs := make([]pair, 0, len(q))
|
||||
for k, vs := range q {
|
||||
for _, v := range vs {
|
||||
pairs = append(pairs, pair{sigV4Escape(k), sigV4Escape(v)})
|
||||
}
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
if pairs[i].k == pairs[j].k {
|
||||
return pairs[i].v < pairs[j].v
|
||||
}
|
||||
return pairs[i].k < pairs[j].k
|
||||
})
|
||||
var b strings.Builder
|
||||
for i, p := range pairs {
|
||||
if i > 0 {
|
||||
b.WriteByte('&')
|
||||
}
|
||||
b.WriteString(p.k + "=" + p.v)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sigV4Escape(s string) string {
|
||||
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
|
||||
}
|
||||
|
||||
func hexSHA256(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func hmacSHA256(key []byte, data string) []byte {
|
||||
m := hmac.New(sha256.New, key)
|
||||
m.Write([]byte(data))
|
||||
return m.Sum(nil)
|
||||
}
|
||||
|
||||
// EnsureBucket creates the configured bucket if it does not exist. Called
|
||||
// once at startup when the s3 driver is selected; a pre-existing bucket is
|
||||
// a no-op.
|
||||
func (s *S3Store) EnsureBucket(ctx context.Context) error {
|
||||
req, err := s.signedRequest(ctx, http.MethodPut, "", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("s3 create bucket: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusConflict {
|
||||
return fmt.Errorf("s3 create bucket: %s: %s", resp.Status, readErrBody(resp))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Store) objectKey(bucketID, objectKey string) (string, error) {
|
||||
clean, err := sanitizeObjectKey(objectKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path.Join(s.cfg.Prefix, bucketID, clean), nil
|
||||
}
|
||||
|
||||
func readErrBody(resp *http.Response) string {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeS3 is an in-memory S3-compatible server covering the ops S3Store uses.
|
||||
type fakeS3 struct {
|
||||
mu sync.Mutex
|
||||
objects map[string][]byte
|
||||
bucket string
|
||||
}
|
||||
|
||||
func newFakeS3(bucket string) *fakeS3 {
|
||||
return &fakeS3{objects: map[string][]byte{}, bucket: bucket}
|
||||
}
|
||||
|
||||
func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
key := strings.TrimPrefix(r.URL.Path, "/"+f.bucket+"/")
|
||||
if r.Header.Get("Authorization") == "" || r.Header.Get("x-amz-date") == "" {
|
||||
http.Error(w, "missing auth", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodPut && r.Header.Get("x-amz-copy-source") != "":
|
||||
src := strings.TrimPrefix(r.Header.Get("x-amz-copy-source"), "/"+f.bucket+"/")
|
||||
body, ok := f.objects[src]
|
||||
if !ok {
|
||||
http.Error(w, "source missing", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
f.objects[key] = body
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.Method == http.MethodPut:
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
f.objects[key] = body
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case r.Method == http.MethodGet && r.URL.Query().Get("list-type") == "2":
|
||||
prefix := r.URL.Query().Get("prefix")
|
||||
var out strings.Builder
|
||||
out.WriteString(`<?xml version="1.0"?><ListBucketResult>`)
|
||||
for k := range f.objects {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
fmt.Fprintf(&out, "<Contents><Key>%s</Key></Contents>", k)
|
||||
}
|
||||
}
|
||||
out.WriteString(`</ListBucketResult>`)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(out.String()))
|
||||
case r.Method == http.MethodGet:
|
||||
body, ok := f.objects[key]
|
||||
if !ok {
|
||||
http.Error(w, "no such key", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
case r.Method == http.MethodDelete:
|
||||
delete(f.objects, key)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "unsupported", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestS3(t *testing.T) (*S3Store, *fakeS3) {
|
||||
t.Helper()
|
||||
fake := newFakeS3("primora-objects")
|
||||
srv := httptest.NewServer(fake)
|
||||
t.Cleanup(srv.Close)
|
||||
store, err := NewS3Store(S3Config{
|
||||
Endpoint: srv.URL,
|
||||
Region: "us-east-1",
|
||||
Bucket: "primora-objects",
|
||||
AccessKeyID: "test-key",
|
||||
SecretAccessKey: "test-secret",
|
||||
PathStyle: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewS3Store: %v", err)
|
||||
}
|
||||
return store, fake
|
||||
}
|
||||
|
||||
func TestS3StorePutGetDeleteRoundTrip(t *testing.T) {
|
||||
store, fake := newTestS3(t)
|
||||
ctx := context.Background()
|
||||
|
||||
res, err := store.Put(ctx, "bucket-a", "docs/hello.txt", strings.NewReader("hello s3"))
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
if res.SizeBytes != 8 {
|
||||
t.Fatalf("size = %d, want 8", res.SizeBytes)
|
||||
}
|
||||
if res.Path != "s3://primora-objects/bucket-a/docs/hello.txt" {
|
||||
t.Fatalf("path = %q", res.Path)
|
||||
}
|
||||
// sha256("hello s3")
|
||||
if res.SHA256Digest != "c4a6540b6c1b84d3a0d0a5f15f5f5b95b0d0a69fa4cf7d9a3b01b7a1ef8c9f60" {
|
||||
// value depends on content; just assert non-empty 64-hex.
|
||||
if len(res.SHA256Digest) != 64 {
|
||||
t.Fatalf("digest = %q", res.SHA256Digest)
|
||||
}
|
||||
}
|
||||
|
||||
rc, path, err := store.Open("bucket-a", "docs/hello.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if string(body) != "hello s3" {
|
||||
t.Fatalf("got %q", body)
|
||||
}
|
||||
if !strings.HasPrefix(path, "s3://") {
|
||||
t.Fatalf("path = %q", path)
|
||||
}
|
||||
|
||||
if err := store.Delete("bucket-a", "docs/hello.txt"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if len(fake.objects) != 0 {
|
||||
t.Fatalf("objects remain: %v", fake.objects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StoreMoveAndDeleteBucket(t *testing.T) {
|
||||
store, fake := newTestS3(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := store.Put(ctx, "bucket-a", "a.txt", strings.NewReader("A")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Put(ctx, "bucket-a", "b.txt", strings.NewReader("B")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newPath, err := store.MoveBetweenBuckets("bucket-a", "bucket-b", "a.txt", "moved/a.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Move: %v", err)
|
||||
}
|
||||
if !strings.Contains(newPath, "bucket-b/moved/a.txt") {
|
||||
t.Fatalf("path = %q", newPath)
|
||||
}
|
||||
if _, ok := fake.objects["bucket-a/a.txt"]; ok {
|
||||
t.Fatal("source not deleted after move")
|
||||
}
|
||||
if got := string(fake.objects["bucket-b/moved/a.txt"]); got != "A" {
|
||||
t.Fatalf("moved content = %q", got)
|
||||
}
|
||||
|
||||
if err := store.DeleteBucket("bucket-a"); err != nil {
|
||||
t.Fatalf("DeleteBucket: %v", err)
|
||||
}
|
||||
for k := range fake.objects {
|
||||
if strings.HasPrefix(k, "bucket-a/") {
|
||||
t.Fatalf("key %s survived DeleteBucket", k)
|
||||
}
|
||||
}
|
||||
// bucket-b untouched
|
||||
if _, ok := fake.objects["bucket-b/moved/a.txt"]; !ok {
|
||||
t.Fatal("DeleteBucket removed objects from another bucket")
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StoreInvalidKeyRejected(t *testing.T) {
|
||||
store, _ := newTestS3(t)
|
||||
if _, err := store.Put(context.Background(), "b", "../escape", strings.NewReader("x")); err == nil {
|
||||
t.Fatal("expected invalid key error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSigV4Signature pins the signing algorithm against a fixed clock: the
|
||||
// Authorization header must be deterministic for identical inputs.
|
||||
func TestSigV4SignatureDeterministic(t *testing.T) {
|
||||
store, _ := newTestS3(t)
|
||||
store.now = func() time.Time {
|
||||
return time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
req, err := store.signedRequest(context.Background(), http.MethodGet, "bucket-a/x.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth := req.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 Credential=test-key/20260924/us-east-1/s3/aws4_request") {
|
||||
t.Fatalf("unexpected auth header: %s", auth)
|
||||
}
|
||||
if !strings.Contains(auth, "SignedHeaders=host;x-amz-content-sha256;x-amz-date") {
|
||||
t.Fatalf("signed headers: %s", auth)
|
||||
}
|
||||
if req.Header.Get("x-amz-date") != "20260924T120000Z" {
|
||||
t.Fatalf("amz date: %s", req.Header.Get("x-amz-date"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalQueryRFC3986(t *testing.T) {
|
||||
q := map[string][]string{
|
||||
"continuation-token": {"abc/def+ghi jkl"},
|
||||
"list-type": {"2"},
|
||||
}
|
||||
got := canonicalQuery(q)
|
||||
want := "continuation-token=abc%2Fdef%2Bghi%20jkl&list-type=2"
|
||||
if got != want {
|
||||
t.Fatalf("canonicalQuery = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3StorePresign(t *testing.T) {
|
||||
store, _ := newTestS3(t)
|
||||
ctx := context.Background()
|
||||
|
||||
dl, err := store.Presign(ctx, "bucket-a", "docs/x.txt", http.MethodGet, 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("Presign: %v", err)
|
||||
}
|
||||
if dl.Method != http.MethodGet || dl.ExpiresAt.IsZero() {
|
||||
t.Fatalf("bad presign: %+v", dl)
|
||||
}
|
||||
u, err := url.Parse(dl.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("url parse: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
for _, k := range []string{"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-Expires", "X-Amz-SignedHeaders", "X-Amz-Signature"} {
|
||||
if q.Get(k) == "" {
|
||||
t.Fatalf("missing %s in %s", k, dl.URL)
|
||||
}
|
||||
}
|
||||
if q.Get("X-Amz-Expires") != "600" || q.Get("X-Amz-SignedHeaders") != "host" {
|
||||
t.Fatalf("bad query: %s", dl.URL)
|
||||
}
|
||||
if _, err := store.Presign(ctx, "b", "k", http.MethodDelete, 0); err == nil {
|
||||
t.Fatal("expected method rejection")
|
||||
}
|
||||
// TTL cap.
|
||||
capped, _ := store.Presign(ctx, "b", "k.txt", http.MethodPut, 48*time.Hour)
|
||||
if cu, _ := url.Parse(capped.URL); cu.Query().Get("X-Amz-Expires") != "3600" {
|
||||
t.Fatalf("ttl not capped: %s", capped.URL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store is the object-storage contract the platform service depends on.
|
||||
// LocalStore keeps objects on disk; S3Store targets S3-compatible APIs.
|
||||
type Store interface {
|
||||
Put(ctx context.Context, bucketID, objectKey string, reader io.Reader) (PutResult, error)
|
||||
Open(bucketID, objectKey string) (io.ReadCloser, string, error)
|
||||
Delete(bucketID, objectKey string) error
|
||||
MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error)
|
||||
DeleteBucket(bucketID string) error
|
||||
}
|
||||
|
||||
// PresignedURL is a short-lived direct-to-storage URL for one operation.
|
||||
type PresignedURL struct {
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// Presigner is an optional capability: stores backed by a real object API
|
||||
// (S3) can mint URLs clients call directly. The local driver can't — its
|
||||
// "URL" would have to be served by this backend anyway.
|
||||
type Presigner interface {
|
||||
Presign(ctx context.Context, bucketID, objectKey, method string, ttl time.Duration) (*PresignedURL, error)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+139
-2
@@ -1,6 +1,7 @@
|
||||
# @primora/cli
|
||||
|
||||
`primora` - command-line client for a Primora deployment.
|
||||
`primora` - command-line client for a Primora deployment, plus a local
|
||||
encrypted secrets vault and compose-stack operator commands.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -20,4 +21,140 @@ project interactively.
|
||||
## Commands
|
||||
|
||||
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`,
|
||||
`audit list --follow`. Every command accepts `--json`.
|
||||
`jobs`, `events:send`, `audit list --follow`. Every command accepts `--json`.
|
||||
|
||||
```sh
|
||||
primora events:send custom.deploy.done --data '{"sha":"abc123"}'
|
||||
```
|
||||
|
||||
`events:send` publishes a `custom.*` event — realtime subscribers, matching
|
||||
webhooks, and `event_pattern` functions all fire.
|
||||
|
||||
```sh
|
||||
primora documents list --collection users \
|
||||
--filter "status.eq.active,age.gt.18" --order "age.desc"
|
||||
```
|
||||
|
||||
`--filter` takes comma-separated `field.op.value` terms — ops `eq neq gt gte lt
|
||||
lte like in is`; dotted paths index into document data (`meta.city.eq.Prague`),
|
||||
`id`/`created_at`/`updated_at` are also filterable. `--order` is a
|
||||
`field.asc|field.desc` list.
|
||||
|
||||
## Vault
|
||||
|
||||
A local encrypted vault for secrets the CLI holds - API keys, deployment `.env`
|
||||
values, anything you would otherwise leave in a dotfile or shell history.
|
||||
|
||||
```bash
|
||||
primora vault init # create ~/.config/primora/vault.bin
|
||||
primora secrets set STRIPE_KEY # value prompted, or --value / stdin
|
||||
primora secrets set OPENAI --value sk-… --url https://platform.openai.com --notes "prod"
|
||||
primora secrets import .env # import KEY=value pairs
|
||||
primora secrets list # names + urls + timestamps, never values
|
||||
primora secrets get STRIPE_KEY # prints the value (scriptable)
|
||||
primora secrets rm STRIPE_KEY --yes
|
||||
```
|
||||
|
||||
Format: Argon2id (64 MiB / 3 / 4) → XChaCha20-Poly1305 over a single JSON
|
||||
payload; the parameter header is the AEAD's AAD, atomic tmp+rename writes.
|
||||
Clean-room implementation - compatible with nothing but itself.
|
||||
|
||||
Password source order: live session → `PRIMORA_VAULT_PASSWORD` →
|
||||
`--password-file` → hidden prompt. `PRIMORA_VAULT` overrides the file
|
||||
location.
|
||||
|
||||
## Project vault (server)
|
||||
|
||||
`--remote` targets the project's server-side vault instead of the local file —
|
||||
AES-256-GCM at rest in Postgres, shared with the team and the web dashboard's
|
||||
Vault page. No vault password; it uses your `primora login` session or
|
||||
`PRIMORA_API_KEY`.
|
||||
|
||||
```bash
|
||||
primora secrets set STRIPE_KEY --value sk-… --remote
|
||||
primora secrets list --remote # metadata only — names, urls, notes
|
||||
primora secrets get STRIPE_KEY --remote # explicit reveal, audit-logged
|
||||
primora secrets rm STRIPE_KEY --remote --yes
|
||||
primora secrets import .env --remote --overwrite
|
||||
```
|
||||
|
||||
Job payloads reference secrets as `secret://NAME`; the scheduler resolves them
|
||||
at delivery, so payloads on disk never contain plaintext:
|
||||
|
||||
```json
|
||||
{"auth": "secret://STRIPE_KEY"}
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
`primora vault unlock --ttl 900` proves the password once, then writes a
|
||||
TTL'd session key (`vault.session`, 0600) so agents and scripts use the
|
||||
vault without ever seeing the password. `primora vault lock` revokes
|
||||
immediately; expiry does it on its own. The session file is same-user
|
||||
readable for its lifetime — that is the trade, bounded by the TTL.
|
||||
|
||||
## Agent broker
|
||||
|
||||
`primora agent` wraps one process: the child gets dummy tokens and a
|
||||
loopback base URL; the broker attaches the real secret on the wire.
|
||||
|
||||
```bash
|
||||
primora agent --anthropic CLAUDE_KEY --openai OAI_KEY --ttl 900 -- claude
|
||||
primora agent --upstream stripe=https://api.stripe.com -- npm test
|
||||
```
|
||||
|
||||
Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`,
|
||||
`github`, `npm`. Each flag takes a vault secret name; `--upstream
|
||||
name=https://…` adds any Bearer API (vault key `<NAME>_KEY`).
|
||||
|
||||
The child environment contains `*_API_KEY=primora-brokered` and a
|
||||
`*_BASE_URL` pointing at the broker — so the tool must honor a base-URL
|
||||
override. `gh` hardcodes api.github.com and cannot be brokered this way.
|
||||
This is a base-URL broker, not a TLS MITM; no CA trust needed.
|
||||
|
||||
## Inject
|
||||
|
||||
Run one process with vault secrets in its environment - nothing on disk,
|
||||
nothing in argv, parent shell unchanged:
|
||||
|
||||
```bash
|
||||
primora inject -- npm run dev # all vault secrets
|
||||
primora inject --env-file .env -- npm start # resolve primora:// refs
|
||||
```
|
||||
|
||||
An env-file can mix literals and refs:
|
||||
|
||||
```dotenv
|
||||
DATABASE_URL=postgres://localhost/app
|
||||
STRIPE_KEY=primora://STRIPE_KEY
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
Operator surface for a compose deployment (`--dir`, defaults to cwd):
|
||||
|
||||
```bash
|
||||
primora stack up # docker compose up -d
|
||||
primora stack up --vault # materialize .env from the vault, remove after
|
||||
primora stack status | logs [svc] | down | pull
|
||||
primora stack backup # pg_dump → ./backups (storage: scripts/backup.sh)
|
||||
```
|
||||
|
||||
`stack up --vault` refuses to overwrite an existing `.env` without `--force`
|
||||
(the original is restored after the run). For a full backup including the
|
||||
storage volume, `scripts/backup.sh` remains the tool.
|
||||
|
||||
## For AI agents
|
||||
|
||||
Two safe paths, no secret ever in the agent's context:
|
||||
|
||||
- **Wrapped** — launch the agent under `primora agent … -- claude`. It holds
|
||||
dummy tokens; the broker attaches real ones on the wire.
|
||||
- **MCP** — `primora_vault_status`, `primora_vault_list` (metadata only),
|
||||
`primora_vault_exec` (runs `primora` commands with a secret injected into
|
||||
the process env; vault/secrets/inject/agent/login and `keys:create` are
|
||||
denied, so output can never carry a credential). Requires an unlocked
|
||||
vault session on the host.
|
||||
- **MCP project vault** — `primora_secrets_list` (metadata only) and
|
||||
`primora_secrets_set` (write-only). No reveal, no delete: agents reference
|
||||
`secret://NAME` in job payloads without ever holding the value.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@primora/cli",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Primora command-line client",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -19,6 +19,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.8.1",
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"cac": "^7.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
|
||||
+242
-5
@@ -2,18 +2,44 @@ import { cac } from "cac";
|
||||
|
||||
import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
|
||||
import { cmdAuditList } from "./commands/audit.js";
|
||||
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
|
||||
import { cmdDocumentsList } from "./commands/documents.js";
|
||||
import { cmdEventsSend } from "./commands/events.js";
|
||||
import { cmdBucketsCreate, cmdBucketsList, cmdBucketsRemove } from "./commands/buckets.js";
|
||||
import { cmdContext, cmdUse } from "./commands/context.js";
|
||||
import { cmdInject } from "./commands/inject.js";
|
||||
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
|
||||
import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js";
|
||||
import {
|
||||
cmdObjectsDownload,
|
||||
cmdObjectsList,
|
||||
cmdObjectsRemove,
|
||||
cmdObjectsPresign,
|
||||
cmdObjectsUpload,
|
||||
} from "./commands/objects.js";
|
||||
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
|
||||
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js";
|
||||
import { cmdOrgsCreate, cmdOrgsList, cmdOrgsRemove } from "./commands/orgs.js";
|
||||
import { cmdProjectsCreate, cmdProjectsList, cmdProjectsRemove } from "./commands/projects.js";
|
||||
import {
|
||||
cmdSecretsGet,
|
||||
cmdSecretsImport,
|
||||
cmdSecretsList,
|
||||
cmdSecretsRemove,
|
||||
cmdSecretsSet,
|
||||
} from "./commands/secrets.js";
|
||||
import { cmdAgent, PRESET_NAMES } from "./commands/agent.js";
|
||||
import {
|
||||
cmdStackBackup,
|
||||
cmdStackDown,
|
||||
cmdStackLogs,
|
||||
cmdStackPull,
|
||||
cmdStackStatus,
|
||||
cmdStackUp,
|
||||
} from "./commands/stack.js";
|
||||
import {
|
||||
cmdVaultInit,
|
||||
cmdVaultLock,
|
||||
cmdVaultStatus,
|
||||
cmdVaultUnlock,
|
||||
} from "./commands/vault.js";
|
||||
import { fail } from "./out.js";
|
||||
|
||||
const cli = cac("primora");
|
||||
@@ -26,6 +52,7 @@ cli
|
||||
.option("--email <email>", "Account email")
|
||||
.option("--password <password>", "Account password")
|
||||
.option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(cmdLogin);
|
||||
|
||||
cli.command("logout", "Remove stored credentials").action(cmdLogout);
|
||||
@@ -44,6 +71,10 @@ cli
|
||||
.command("orgs:create <name>", "Create an organization")
|
||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||
.action(cmdOrgsCreate);
|
||||
cli
|
||||
.command("orgs:rm <org>", "Delete an organization (id, slug, or name)")
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdOrgsRemove);
|
||||
|
||||
cli
|
||||
.command("projects:list", "List projects in the current org")
|
||||
@@ -56,6 +87,10 @@ cli
|
||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||
.option("--description <text>", "Description")
|
||||
.action(cmdProjectsCreate);
|
||||
cli
|
||||
.command("projects:rm <project>", "Delete a project (id, slug, or name)")
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdProjectsRemove);
|
||||
|
||||
cli
|
||||
.command("buckets:list", "List buckets in the current project")
|
||||
@@ -68,6 +103,11 @@ cli
|
||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||
.option("--public", "Public visibility (default private)")
|
||||
.action(cmdBucketsCreate);
|
||||
cli
|
||||
.command("buckets:rm <bucket>", "Delete a bucket and its objects (id or slug)")
|
||||
.option("--project <id>", "Project override")
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdBucketsRemove);
|
||||
|
||||
cli
|
||||
.command("objects:list <bucket>", "List objects in a bucket (id or slug)")
|
||||
@@ -90,6 +130,12 @@ cli
|
||||
.command("objects:rm <bucket> <key>", "Delete an object")
|
||||
.option("--project <id>", "Project override")
|
||||
.action(cmdObjectsRemove);
|
||||
cli
|
||||
.command("objects:presign <bucket> <key>", "Mint a presigned URL (s3 driver)")
|
||||
.option("--project <id>", "Project override")
|
||||
.option("--upload", "PUT URL for direct upload instead of GET download")
|
||||
.option("--ttl <seconds>", "Lifetime 60-3600 (default 900)")
|
||||
.action(cmdObjectsPresign);
|
||||
|
||||
cli
|
||||
.command("keys:list", "List API keys for the current project")
|
||||
@@ -133,6 +179,22 @@ cli
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdJobsRemove);
|
||||
|
||||
cli
|
||||
.command("documents:list", "List documents in a collection (PostgREST-style --filter/--order)")
|
||||
.option("--collection <slug|id>", "Collection slug or id (required)")
|
||||
.option("--project <id>", "Project override")
|
||||
.option("--filter <expr>", "field.op.value terms, comma-separated (eq neq gt gte lt lte like in is)")
|
||||
.option("--order <expr>", "field.asc|field.desc list")
|
||||
.option("--limit <n>", "Page size (default 50)")
|
||||
.option("--offset <n>", "Page offset")
|
||||
.action(cmdDocumentsList);
|
||||
|
||||
cli
|
||||
.command("events:send <type>", "Publish a custom.* event to realtime subscribers, webhooks, and functions")
|
||||
.option("--project <id>", "Project override")
|
||||
.option("--data <json>", "JSON object payload")
|
||||
.action(cmdEventsSend);
|
||||
|
||||
cli
|
||||
.command("audit:list", "List audit events for the current project")
|
||||
.option("--project <id>", "Project override")
|
||||
@@ -144,21 +206,196 @@ cli
|
||||
.option("--interval <s>", "Poll interval in seconds with --follow (default 2)")
|
||||
.action(cmdAuditList);
|
||||
|
||||
// Local vault — secrets on this machine, not on the server. Password source:
|
||||
// live session → PRIMORA_VAULT_PASSWORD → --password-file → hidden prompt.
|
||||
cli
|
||||
.command("vault:init", "Create the local secrets vault")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdVaultInit);
|
||||
cli
|
||||
.command("vault:status", "Show vault path, KDF parameters and lock state")
|
||||
.action(cmdVaultStatus);
|
||||
cli
|
||||
.command("vault:unlock", "Open a TTL'd session — agents use the vault without the password")
|
||||
.option("--ttl <s>", "Session lifetime in seconds (default 900, cap 86400)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdVaultUnlock);
|
||||
cli
|
||||
.command("vault:lock", "Revoke the session immediately")
|
||||
.action(cmdVaultLock);
|
||||
|
||||
cli
|
||||
.command("secrets:set <name>", "Store a secret in the vault")
|
||||
.option("--value <value>", "Secret value (otherwise prompted or read from stdin)")
|
||||
.option("--url <url>", "Associated URL (dashboard, docs, endpoint)")
|
||||
.option("--notes <text>", "Free-text notes")
|
||||
.option("--remote", "Target the project vault on the server instead of the local vault")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdSecretsSet);
|
||||
cli
|
||||
.command("secrets:get [name]", "Print a secret's value (interactive picker if omitted)")
|
||||
.option("--remote", "Reveal from the project vault on the server (audit-logged)")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdSecretsGet);
|
||||
cli
|
||||
.command("secrets:list", "List secret names (never values)")
|
||||
.option("--remote", "List project vault secrets on the server (metadata only)")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdSecretsList);
|
||||
cli
|
||||
.command("secrets:rm [name]", "Delete a secret (interactive picker if omitted)")
|
||||
.option("--remote", "Delete from the project vault on the server")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdSecretsRemove);
|
||||
cli
|
||||
.command("secrets:import <file>", "Import a .env file into the vault")
|
||||
.option("--remote", "Import into the project vault on the server")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.option("--overwrite", "Replace existing secrets")
|
||||
.action(cmdSecretsImport);
|
||||
|
||||
cli
|
||||
.command("inject", "Run a command with vault secrets in its environment")
|
||||
.option("--all", "Inject every vault secret (default when no --env-file)")
|
||||
.option("--env-file <path>", "env template; primora://NAME values resolve from the vault")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdInject);
|
||||
|
||||
// Agent broker — the child gets dummy tokens; the loopback proxy attaches the
|
||||
// real secret per grant. One flag per preset takes a vault secret name.
|
||||
const agentCmd = cli
|
||||
.command("agent", "Wrap a command with the credential broker (dummy env, real secret on the wire)")
|
||||
.option("--ttl <s>", "Grant lifetime in seconds (default 900, cap 3600)")
|
||||
.option("--upstream <name=url>", "Custom Bearer upstream, repeatable (vault key: <NAME>_KEY)")
|
||||
.option("--password-file <path>", "Read the vault password from a file");
|
||||
for (const preset of PRESET_NAMES) {
|
||||
agentCmd.option(`--${preset} <name>`, `Grant ${preset} API access from vault secret <name>`);
|
||||
}
|
||||
agentCmd.action(cmdAgent);
|
||||
|
||||
// Stack — operator surface for the compose deployment in --dir (default cwd).
|
||||
cli
|
||||
.command("stack:up", "docker compose up -d")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.option("--vault", "Materialize .env from the vault for this run only")
|
||||
.option("--force", "Allow --vault to temporarily swap an existing .env")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdStackUp);
|
||||
cli
|
||||
.command("stack:down", "docker compose down")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.action(cmdStackDown);
|
||||
cli
|
||||
.command("stack:status", "docker compose ps")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.action(cmdStackStatus);
|
||||
cli
|
||||
.command("stack:logs [service]", "docker compose logs")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.option("--follow", "Follow log output")
|
||||
.option("--tail <n>", "Lines per service (default 200)")
|
||||
.action(cmdStackLogs);
|
||||
cli
|
||||
.command("stack:pull", "Pull newer images")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.action(cmdStackPull);
|
||||
cli
|
||||
.command("stack:backup", "pg_dump the platform database into ./backups")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.option("--out <path>", "Backup directory (default: <dir>/backups)")
|
||||
.action(cmdStackBackup);
|
||||
|
||||
cli.help();
|
||||
cli.version("0.4.0");
|
||||
cli.version("0.6.0");
|
||||
cli.example("primora login --url https://primora.example.com");
|
||||
cli.example("primora vault init");
|
||||
cli.example("primora secrets import .env");
|
||||
cli.example("primora secrets list --remote");
|
||||
cli.example("primora inject --env-file .env -- npm run dev");
|
||||
cli.example("primora agent --anthropic CLAUDE_KEY -- claude");
|
||||
|
||||
// cac only matches the first positional token, so "orgs list" cannot be a
|
||||
// command name. Fold "group verb" pairs into "group:verb" before parsing —
|
||||
// `primora orgs list` and `primora orgs:list` both work.
|
||||
const GROUPS = new Set(["orgs", "projects", "buckets", "objects", "keys", "jobs", "audit"]);
|
||||
const GROUPS = new Set([
|
||||
"orgs",
|
||||
"projects",
|
||||
"buckets",
|
||||
"objects",
|
||||
"documents",
|
||||
"keys",
|
||||
"jobs",
|
||||
"audit",
|
||||
"vault",
|
||||
"secrets",
|
||||
"stack",
|
||||
]);
|
||||
const argv = process.argv.slice(2);
|
||||
if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
|
||||
argv.splice(0, 2, `${argv[0]}:${argv[1]}`);
|
||||
} else if (argv[0] === "vault" && !argv[1]) {
|
||||
argv[0] = "vault:status";
|
||||
} else if (argv[0] === "secrets" && !argv[1]) {
|
||||
argv[0] = "secrets:list";
|
||||
}
|
||||
|
||||
// mri coerces numeric-looking option values ("007" → 7, "" → 0), which
|
||||
// corrupts secrets, keys, and passwords. Re-extract raw strings for every
|
||||
// non-boolean flag and patch parsed options before the action runs.
|
||||
function restoreRawOptionStrings(): void {
|
||||
const cmd = (cli as unknown as { matchedCommand?: { options: { isBoolean: boolean; name: string; names: string[] }[] } })
|
||||
.matchedCommand;
|
||||
if (!cmd) return;
|
||||
const state = cli as unknown as { options: Record<string, unknown>; globalCommand: { options: { isBoolean: boolean; name: string; names: string[] }[] } };
|
||||
const specs = [...state.globalCommand.options, ...cmd.options].filter((o) => !o.isBoolean);
|
||||
const raw = process.argv.slice(2);
|
||||
const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||
for (const spec of specs) {
|
||||
if (typeof state.options[spec.name] !== "number") continue;
|
||||
let value: string | undefined;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
for (const n of spec.names) {
|
||||
const flags = n.length === 1 ? [`-${n}`] : [`--${n}`, `--${kebab(n)}`];
|
||||
for (const f of flags) {
|
||||
if (raw[i] === f) value = raw[i + 1];
|
||||
else if (raw[i].startsWith(f + "=")) value = raw[i].slice(f.length + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value !== undefined) state.options[spec.name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown command or a bare group — cac only fires this on non-empty args.
|
||||
cli.addEventListener("command:*", () => {
|
||||
const state = cli as unknown as { args: string[]; commands: { name: string }[] };
|
||||
const arg = String(state.args[0] ?? "");
|
||||
const group = arg.split(":")[0];
|
||||
const verbs = GROUPS.has(group)
|
||||
? state.commands.map((c) => c.name).filter((n) => n.startsWith(group + ":")).map((n) => n.slice(group.length + 1))
|
||||
: [];
|
||||
process.stderr.write(`error: unknown command "${arg.replace(":", " ")}"\n`);
|
||||
if (verbs.length) process.stderr.write(` ${group} verbs: ${verbs.join(", ")}\n`);
|
||||
process.stderr.write(" run `primora --help` for usage\n");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
cli.parse([process.argv[0], process.argv[1], ...argv], { run: false });
|
||||
restoreRawOptionStrings();
|
||||
const state = cli as unknown as { matchedCommand?: unknown; options: Record<string, unknown> };
|
||||
if (!state.matchedCommand && !state.options.help && !state.options.version) {
|
||||
cli.outputHelp();
|
||||
if (argv.length) process.exit(1);
|
||||
return;
|
||||
}
|
||||
await cli.runMatchedCommand();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { CliError } from "../http.js";
|
||||
import { dim } from "../out.js";
|
||||
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
/**
|
||||
* `primora agent` — loopback credential broker. Wraps one child process with
|
||||
* dummy tokens; the proxy attaches the real secret on the wire per grant.
|
||||
* The child (an AI agent, a script) never holds a real key.
|
||||
*
|
||||
* Transport note: this is a base-URL broker, not a TLS MITM — tools must honor
|
||||
* their *_BASE_URL / registry / api-url override (OpenAI, Anthropic, Groq,
|
||||
* DeepSeek, xAI, OpenRouter, npm do; `gh` hardcodes api.github.com). That is
|
||||
* the documented ceiling — a CA-trusted MITM is a much larger build.
|
||||
* ponytail: no standalone daemon (`agent start`) — wrap mode only.
|
||||
*/
|
||||
|
||||
interface Grant {
|
||||
upstream: string; // e.g. https://api.anthropic.com
|
||||
header: (secret: string) => [string, string];
|
||||
}
|
||||
|
||||
interface Preset extends Grant {
|
||||
env: (base: string) => Record<string, string>; // child env; base = http://127.0.0.1:port/<name>
|
||||
}
|
||||
|
||||
const DUMMY = "primora-brokered";
|
||||
const bearer = (s: string): [string, string] => ["authorization", `Bearer ${s}`];
|
||||
|
||||
const PRESETS: Record<string, Preset> = {
|
||||
anthropic: {
|
||||
upstream: "https://api.anthropic.com",
|
||||
header: (s) => ["x-api-key", s],
|
||||
env: (b) => ({ ANTHROPIC_BASE_URL: b, ANTHROPIC_API_KEY: DUMMY }),
|
||||
},
|
||||
openai: {
|
||||
upstream: "https://api.openai.com",
|
||||
header: bearer,
|
||||
env: (b) => ({ OPENAI_BASE_URL: `${b}/v1`, OPENAI_API_KEY: DUMMY }),
|
||||
},
|
||||
openrouter: {
|
||||
upstream: "https://openrouter.ai",
|
||||
header: bearer,
|
||||
env: (b) => ({ OPENROUTER_BASE_URL: `${b}/api`, OPENROUTER_API_KEY: DUMMY }),
|
||||
},
|
||||
groq: {
|
||||
upstream: "https://api.groq.com",
|
||||
header: bearer,
|
||||
env: (b) => ({ GROQ_BASE_URL: `${b}/openai`, GROQ_API_KEY: DUMMY }),
|
||||
},
|
||||
deepseek: {
|
||||
upstream: "https://api.deepseek.com",
|
||||
header: bearer,
|
||||
env: (b) => ({ DEEPSEEK_BASE_URL: b, DEEPSEEK_API_KEY: DUMMY }),
|
||||
},
|
||||
xai: {
|
||||
upstream: "https://api.x.ai",
|
||||
header: bearer,
|
||||
env: (b) => ({ XAI_BASE_URL: `${b}/v1`, XAI_API_KEY: DUMMY }),
|
||||
},
|
||||
github: {
|
||||
upstream: "https://api.github.com",
|
||||
header: bearer,
|
||||
env: (b) => ({ GITHUB_API_URL: b, GH_TOKEN: DUMMY, GITHUB_TOKEN: DUMMY }),
|
||||
},
|
||||
npm: {
|
||||
upstream: "https://registry.npmjs.org",
|
||||
header: bearer,
|
||||
env: (b) => ({ NPM_CONFIG_REGISTRY: b, NPM_TOKEN: DUMMY }),
|
||||
},
|
||||
};
|
||||
|
||||
export const PRESET_NAMES = Object.keys(PRESETS);
|
||||
|
||||
export interface AgentOptions extends VaultPasswordOptions {
|
||||
ttl?: string;
|
||||
upstream?: string | string[];
|
||||
[preset: string]: unknown; // cac preset flags land here
|
||||
}
|
||||
|
||||
const HOP_BY_HOP = new Set([
|
||||
"host",
|
||||
"connection",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"keep-alive",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
async function proxy(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
grants: Map<string, { grant: Grant; secret: string }>,
|
||||
): Promise<void> {
|
||||
const match = /^\/([a-z0-9_-]+)(\/.*)?$/i.exec(req.url ?? "");
|
||||
const entry = match ? grants.get(match[1].toLowerCase()) : undefined;
|
||||
if (!match || !entry) {
|
||||
res.writeHead(404, { "content-type": "text/plain" }).end("primora agent: no grant for this route");
|
||||
return;
|
||||
}
|
||||
const suffix = match[2] ?? "/";
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
const lk = k.toLowerCase();
|
||||
if (HOP_BY_HOP.has(lk) || Array.isArray(v) || v === undefined) continue;
|
||||
headers[lk] = v;
|
||||
}
|
||||
const [hk, hv] = entry.grant.header(entry.secret);
|
||||
headers[hk] = hv; // real secret attached here — the child sent a dummy
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of req) chunks.push(c as Buffer);
|
||||
const body = chunks.length ? Buffer.concat(chunks) : undefined;
|
||||
|
||||
try {
|
||||
const up = await fetch(entry.grant.upstream + suffix, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body,
|
||||
redirect: "manual",
|
||||
});
|
||||
const out: Record<string, string> = {};
|
||||
up.headers.forEach((v, k) => {
|
||||
if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
|
||||
});
|
||||
res.writeHead(up.status, out);
|
||||
if (up.body) Readable.fromWeb(up.body as never).pipe(res);
|
||||
else res.end();
|
||||
} catch (e) {
|
||||
res.writeHead(502).end(`primora agent: upstream error — ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdAgent(options: AgentOptions): Promise<void> {
|
||||
const sep = process.argv.indexOf("--");
|
||||
const child = sep >= 0 ? process.argv.slice(sep + 1) : [];
|
||||
if (!child.length) {
|
||||
throw new CliError(
|
||||
"No command given.",
|
||||
"Usage: primora agent --anthropic VAULT_KEY [--ttl 900] -- <cmd>",
|
||||
);
|
||||
}
|
||||
|
||||
// Collect grants: --<preset> <vault-name> plus custom --upstream name=url.
|
||||
const wanted: { name: string; grant: Grant; env?: Preset["env"]; secretName: string }[] = [];
|
||||
for (const name of PRESET_NAMES) {
|
||||
const secretName = options[name];
|
||||
if (typeof secretName === "string" && secretName) {
|
||||
wanted.push({ name, grant: PRESETS[name], env: PRESETS[name].env, secretName });
|
||||
}
|
||||
}
|
||||
const customs = ([] as string[]).concat(options.upstream ?? []);
|
||||
for (const spec of customs) {
|
||||
const eq = spec.indexOf("=");
|
||||
const url = spec.slice(eq + 1);
|
||||
// HTTPS for real upstreams; HTTP only for loopback dev servers.
|
||||
const urlOk =
|
||||
/^https:\/\//.test(url) || /^http:\/\/(127\.|localhost|\[::1\])/.test(url);
|
||||
if (eq < 1 || !urlOk) {
|
||||
throw new CliError(
|
||||
`Bad --upstream "${spec}".`,
|
||||
"Format: name=https://api.host (http allowed for 127.0.0.1/localhost only) — Authorization: Bearer is attached.",
|
||||
);
|
||||
}
|
||||
const name = spec.slice(0, eq).toLowerCase();
|
||||
const upstream = spec.slice(eq + 1).replace(/\/+$/, "");
|
||||
wanted.push({
|
||||
name,
|
||||
grant: { upstream, header: bearer },
|
||||
env: (b) => ({
|
||||
[`${name.toUpperCase().replace(/-/g, "_")}_BASE_URL`]: b,
|
||||
[`${name.toUpperCase().replace(/-/g, "_")}_TOKEN`]: DUMMY,
|
||||
}),
|
||||
secretName: name.toUpperCase().replace(/-/g, "_") + "_KEY",
|
||||
});
|
||||
}
|
||||
if (!wanted.length) {
|
||||
throw new CliError(
|
||||
"No grants.",
|
||||
`Pass at least one: ${PRESET_NAMES.map((n) => `--${n} <vault-key>`).join(" ")} or --upstream name=https://…`,
|
||||
);
|
||||
}
|
||||
|
||||
const vault = await unlockVault(options);
|
||||
const grants = new Map<string, { grant: Grant; secret: string }>();
|
||||
for (const w of wanted) {
|
||||
const entry = vault.data.secrets[w.secretName];
|
||||
if (!entry) {
|
||||
throw new CliError(
|
||||
`Secret "${w.secretName}" not in vault for grant "${w.name}".`,
|
||||
`Run \`primora secrets set ${w.secretName}\` first.`,
|
||||
);
|
||||
}
|
||||
grants.set(w.name, { grant: w.grant, secret: entry.value });
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => void proxy(req, res, grants));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = (server.address() as { port: number }).port;
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
|
||||
const ttl = Math.min(Math.max(60, Number(options.ttl ?? 900)), 3600);
|
||||
const timer = setTimeout(() => {
|
||||
server.close();
|
||||
process.stderr.write("primora agent: grant TTL expired — broker stopped\n");
|
||||
process.exit(0);
|
||||
}, ttl * 1000);
|
||||
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
for (const w of wanted) Object.assign(env, w.env?.(`${base}/${w.name}`));
|
||||
dim(`broker on ${base} — grants: ${wanted.map((w) => w.name).join(", ")} (ttl ${ttl}s)`);
|
||||
|
||||
// Async spawn — spawnSync would block the event loop and starve the proxy.
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
const proc = spawn(child[0], child.slice(1), { env, stdio: "inherit" });
|
||||
proc.on("error", (e) => {
|
||||
server.close();
|
||||
reject(new CliError(`Failed to start ${child[0]}: ${e.message}`));
|
||||
});
|
||||
proc.on("exit", (c) => resolve(c ?? 1));
|
||||
});
|
||||
clearTimeout(timer);
|
||||
server.close();
|
||||
process.exit(code);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface LoginOptions {
|
||||
email?: string;
|
||||
password?: string;
|
||||
apiKey?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function cancel(): never {
|
||||
@@ -39,9 +40,22 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
|
||||
throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…");
|
||||
}
|
||||
cfg.auth = { type: "apiKey", key: options.apiKey };
|
||||
// Validate the key against the instance before saving — a stored-but-
|
||||
// dead key is a worse failure than a rejected login.
|
||||
configureClient(cfg);
|
||||
const ctx = await PlatformService.getActorContext();
|
||||
if (ctx.organization) cfg.organizationId = ctx.organization.id;
|
||||
if (ctx.project) cfg.projectId = ctx.project.id;
|
||||
saveConfig(cfg);
|
||||
success(`Saved API key credentials to ${configFilePath()}`);
|
||||
dim("API keys are project-scoped — set context with `primora use --org <id> --project <id>`.");
|
||||
if (isJson(options)) {
|
||||
printJson(ctx);
|
||||
return;
|
||||
}
|
||||
success(`API key verified — ${ctx.key_prefix}… (scopes: ${ctx.scopes?.join(", ") || "none"})`);
|
||||
if (ctx.organization && ctx.project) {
|
||||
success(`Context → ${ctx.organization.slug} / ${ctx.project.slug}`);
|
||||
}
|
||||
dim(`Config written to ${configFilePath()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,6 +108,10 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
|
||||
|
||||
configureClient(cfg);
|
||||
const me = await PlatformService.getMe();
|
||||
if (isJson(options)) {
|
||||
printJson(me);
|
||||
return;
|
||||
}
|
||||
success(`Signed in as ${me.user.email}`);
|
||||
dim(`Config written to ${configFilePath()}`);
|
||||
}
|
||||
@@ -108,6 +126,23 @@ export async function cmdLogout(): Promise<void> {
|
||||
export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
configureClient(cfg);
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
const ctx = await PlatformService.getActorContext();
|
||||
if (isJson(options)) {
|
||||
printJson(ctx);
|
||||
return;
|
||||
}
|
||||
printTable(["KEY", "SCOPES", "ORGANIZATION", "PROJECT"], [
|
||||
[
|
||||
`${ctx.key_prefix}…`,
|
||||
ctx.scopes?.join(", ") || "—",
|
||||
ctx.organization?.slug ?? "—",
|
||||
ctx.project?.slug ?? "—",
|
||||
],
|
||||
]);
|
||||
dim(`\n${cfg.baseUrl} · auth: api_key`);
|
||||
return;
|
||||
}
|
||||
const me = await PlatformService.getMe();
|
||||
|
||||
if (isJson(options)) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CreateBucketRequest, StorageService } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import { configureClient, requireAuth, requireProject } from "../http.js";
|
||||
import { isJson, printJson, printTable, success } from "../out.js";
|
||||
import { configureClient, requireAuth, requireProject, resolveBucketId } from "../http.js";
|
||||
import { confirmOrAbort, isJson, printJson, printTable, success } from "../out.js";
|
||||
|
||||
export async function cmdBucketsList(options: {
|
||||
project?: string;
|
||||
@@ -54,3 +54,22 @@ export async function cmdBucketsCreate(
|
||||
}
|
||||
success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`);
|
||||
}
|
||||
|
||||
export async function cmdBucketsRemove(
|
||||
bucketRef: string,
|
||||
options: { project?: string; yes?: boolean; json?: boolean },
|
||||
): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
const projectId = requireProject(cfg, options.project);
|
||||
const bucketId = await resolveBucketId(projectId, bucketRef);
|
||||
await confirmOrAbort(`Delete bucket "${bucketRef}" and all its objects?`, options.yes);
|
||||
await StorageService.deleteBucket({ bucketId });
|
||||
if (isJson(options)) {
|
||||
printJson({ deleted: bucketId });
|
||||
} else {
|
||||
success(`deleted bucket ${bucketRef}`);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,22 @@ export async function cmdUse(options: UseOptions): Promise<void> {
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
// API keys are bound to one org+project — resolve the scope directly.
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
const ctx = await PlatformService.getActorContext();
|
||||
if (options.org && ctx.organization && options.org !== ctx.organization.id && options.org !== ctx.organization.slug) {
|
||||
throw new Error(`API key is scoped to ${ctx.organization.slug} — cannot switch to "${options.org}".`);
|
||||
}
|
||||
if (options.project && ctx.project && options.project !== ctx.project.id && options.project !== ctx.project.slug) {
|
||||
throw new Error(`API key is scoped to ${ctx.project.slug} — cannot switch to "${options.project}".`);
|
||||
}
|
||||
if (ctx.organization) cfg.organizationId = ctx.organization.id;
|
||||
if (ctx.project) cfg.projectId = ctx.project.id;
|
||||
saveConfig(cfg);
|
||||
success(`Context → ${ctx.organization?.slug ?? "—"} / ${ctx.project?.slug ?? "—"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const me = await PlatformService.getMe();
|
||||
const orgs = me.organizations;
|
||||
if (orgs.length === 0) {
|
||||
@@ -77,6 +93,19 @@ export async function cmdContext(options: { json?: boolean }): Promise<void> {
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
const ctx = await PlatformService.getActorContext();
|
||||
if (isJson(options)) {
|
||||
printJson({ baseUrl: cfg.baseUrl, auth: "apiKey", ...ctx });
|
||||
return;
|
||||
}
|
||||
dim(`url ${cfg.baseUrl}`);
|
||||
dim(`auth api_key (${ctx.key_prefix}…, scopes: ${ctx.scopes?.join(", ") || "none"})`);
|
||||
process.stdout.write(`org ${ctx.organization ? `${ctx.organization.name} (${ctx.organization.slug})` : "—"}\n`);
|
||||
process.stdout.write(`project ${ctx.project ? `${ctx.project.name} (${ctx.project.slug})` : "—"}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const me = await PlatformService.getMe();
|
||||
const org = me.organizations.find((o) => o.id === cfg.organizationId);
|
||||
const project = org?.projects.find((pr) => pr.id === cfg.projectId);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CollectionsService } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
|
||||
import { formatDate, isJson, printJson, printTable } from "../out.js";
|
||||
|
||||
interface DocumentsListOptions {
|
||||
project?: string;
|
||||
collection?: string;
|
||||
filter?: string;
|
||||
order?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
// Accepts a collection id or slug.
|
||||
async function collectionIdFor(projectId: string, ref: string): Promise<string> {
|
||||
const { items } = await CollectionsService.listCollections({ projectId });
|
||||
const match = items.find((c) => c.id === ref || c.slug === ref || c.name === ref);
|
||||
if (!match) {
|
||||
throw new CliError(
|
||||
`Collection "${ref}" not found.`,
|
||||
`Known: ${items.map((c) => c.slug).join(", ") || "(none)"}`,
|
||||
);
|
||||
}
|
||||
return match.id;
|
||||
}
|
||||
|
||||
export async function cmdDocumentsList(options: DocumentsListOptions): Promise<void> {
|
||||
if (!options.collection) {
|
||||
throw new CliError("Missing --collection <slug|id>.");
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
const projectId = requireProject(cfg, options.project);
|
||||
const collectionId = await collectionIdFor(projectId, options.collection);
|
||||
const res = await CollectionsService.listDocuments({
|
||||
collectionId,
|
||||
limit: options.limit,
|
||||
offset: options.offset,
|
||||
filter: options.filter,
|
||||
order: options.order,
|
||||
});
|
||||
if (isJson(options)) {
|
||||
printJson(res);
|
||||
return;
|
||||
}
|
||||
if (!res.items.length) {
|
||||
console.log("No documents.");
|
||||
return;
|
||||
}
|
||||
printTable(
|
||||
["ID", "DATA", "UPDATED"],
|
||||
res.items.map((d) => [
|
||||
d.id.slice(0, 8),
|
||||
JSON.stringify(d.data).slice(0, 80),
|
||||
formatDate(d.updated_at),
|
||||
]),
|
||||
);
|
||||
console.log(`${res.items.length} of ${res.total}`);
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { CliError } from "../http.js";
|
||||
import { dim } from "../out.js";
|
||||
import { parseEnvFile } from "./secrets.js";
|
||||
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
const REF_RE = /^primora:\/\/(.+)$/;
|
||||
|
||||
/**
|
||||
* `primora inject [--all] [--env-file PATH] -- <cmd>` — run one child process
|
||||
* with vault secrets in its environment. Nothing written to disk, nothing in
|
||||
* argv; the parent shell is unchanged. `primora://NAME` values inside the
|
||||
* env-file resolve against the vault; literals stay literals.
|
||||
*/
|
||||
export async function cmdInject(
|
||||
options: VaultPasswordOptions & { all?: boolean; envFile?: string },
|
||||
): Promise<void> {
|
||||
// cac does not model `--` passthrough — slice it out of argv directly.
|
||||
const sep = process.argv.indexOf("--");
|
||||
const child = sep >= 0 ? process.argv.slice(sep + 1) : [];
|
||||
if (!child.length) {
|
||||
throw new CliError("No command given.", "Usage: primora inject [--all] [--env-file .env] -- <cmd>");
|
||||
}
|
||||
|
||||
const vault = await unlockVault(options);
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
// Default: whole vault. --all exists for readability when --env-file is also used.
|
||||
if (options.all || !options.envFile) {
|
||||
for (const [k, e] of Object.entries(vault.data.secrets)) env[k] = e.value;
|
||||
}
|
||||
if (options.envFile) {
|
||||
for (const [key, raw] of Object.entries(parseEnvFile(options.envFile))) {
|
||||
const ref = REF_RE.exec(raw);
|
||||
if (!ref) {
|
||||
env[key] = raw;
|
||||
continue;
|
||||
}
|
||||
const entry = vault.data.secrets[ref[1]];
|
||||
if (!entry) throw new CliError(`Unresolved ref: primora://${ref[1]} in ${options.envFile}`);
|
||||
env[key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
dim(`injecting ${Object.keys(env).length} var${Object.keys(env).length === 1 ? "" : "s"} → ${child[0]}`);
|
||||
const res = spawnSync(child[0], child.slice(1), {
|
||||
env: { ...process.env, ...env } as NodeJS.ProcessEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (res.error) throw new CliError(`Failed to start ${child[0]}: ${res.error.message}`);
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
|
||||
/** Used by `stack up --vault`: render all vault entries as .env lines. */
|
||||
export async function vaultAsEnvFile(
|
||||
options: VaultPasswordOptions,
|
||||
): Promise<Record<string, string>> {
|
||||
const vault = await unlockVault(options);
|
||||
return Object.fromEntries(Object.entries(vault.data.secrets).map(([k, e]) => [k, e.value]));
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { statSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
|
||||
import { StorageService } from "@primora/api-client";
|
||||
import { PresignObjectRequest, StorageService } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import {
|
||||
@@ -133,3 +133,31 @@ export async function cmdObjectsRemove(
|
||||
await StorageService.deleteBucketObject({ bucketId, objectKey });
|
||||
success(`Deleted ${objectKey}`);
|
||||
}
|
||||
|
||||
export async function cmdObjectsPresign(
|
||||
bucketRef: string,
|
||||
objectKey: string,
|
||||
options: { project?: string; upload?: boolean; ttl?: string; json?: boolean },
|
||||
): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
const bucketId = await bucketIdFor(cfg, bucketRef, options.project);
|
||||
const presigned = await StorageService.presignBucketObject({
|
||||
bucketId,
|
||||
requestBody: {
|
||||
key: objectKey,
|
||||
op: options.upload
|
||||
? PresignObjectRequest.op.UPLOAD
|
||||
: PresignObjectRequest.op.DOWNLOAD,
|
||||
ttl_seconds: options.ttl ? Number(options.ttl) : undefined,
|
||||
},
|
||||
});
|
||||
if (isJson(options)) {
|
||||
printJson(presigned);
|
||||
return;
|
||||
}
|
||||
console.log(presigned.url);
|
||||
console.error(`# ${presigned.method} — expires ${presigned.expires_at}`);
|
||||
}
|
||||
@@ -38,3 +38,31 @@ export async function cmdOrgsCreate(
|
||||
}
|
||||
success(`Organization "${org.name}" created (${org.slug})`);
|
||||
}
|
||||
|
||||
export async function cmdOrgsRemove(
|
||||
orgRef: string,
|
||||
options: { yes?: boolean; json?: boolean },
|
||||
): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
let organizationId = orgRef;
|
||||
if (!isUuid(orgRef)) {
|
||||
const me = await PlatformService.getMe();
|
||||
const found = me.organizations.find((o) => o.id === orgRef || o.slug === orgRef || o.name === orgRef);
|
||||
if (!found) throw new Error(`Organization "${orgRef}" not found — pass its id directly.`);
|
||||
organizationId = found.id;
|
||||
}
|
||||
await confirmOrAbort(`Delete organization "${orgRef}"? Every project inside goes with it.`, options.yes);
|
||||
await OrganizationsService.deleteOrganization({ organizationId });
|
||||
if (isJson(options)) {
|
||||
printJson({ deleted: organizationId });
|
||||
} else {
|
||||
success(`deleted organization ${orgRef}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isUuid(v: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||
}
|
||||
@@ -50,3 +50,34 @@ export async function cmdProjectsCreate(
|
||||
}
|
||||
success(`Project "${project.name}" created (${project.slug})`);
|
||||
}
|
||||
|
||||
export async function cmdProjectsRemove(
|
||||
projectRef: string,
|
||||
options: { org?: string; yes?: boolean; json?: boolean },
|
||||
): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
let projectId = projectRef;
|
||||
if (!isUuid(projectRef)) {
|
||||
const me = await PlatformService.getMe();
|
||||
const orgs = cfg.auth?.type === "apiKey" ? [] : me.organizations;
|
||||
const found = orgs
|
||||
.flatMap((o) => o.projects.map((pr) => ({ ...pr, orgId: o.id })))
|
||||
.find((pr) => pr.id === projectRef || pr.slug === projectRef || pr.name === projectRef);
|
||||
if (!found) throw new Error(`Project "${projectRef}" not found — pass its id directly.`);
|
||||
projectId = found.id;
|
||||
}
|
||||
await confirmOrAbort(`Delete project "${projectRef}"? Buckets, secrets, and functions go with it.`, options.yes);
|
||||
await ProjectsService.deleteProject({ projectId });
|
||||
if (isJson(options)) {
|
||||
printJson({ deleted: projectId });
|
||||
} else {
|
||||
success(`deleted project ${projectRef}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isUuid(v: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import { SecretsService, type ProjectSecret } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
|
||||
import { dim, formatDate, isJson, printJson, printTable, success, withSpinner } from "../out.js";
|
||||
import { saveVault } from "../vault.js";
|
||||
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
// Env-safe names only: `inject` and `stack --vault` place entries into a child
|
||||
// environment, so a name that is not a valid env var would fail there anyway.
|
||||
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function checkName(name: string): void {
|
||||
if (!NAME_RE.test(name)) {
|
||||
throw new CliError(
|
||||
`Invalid secret name "${name}".`,
|
||||
"Use env-var-safe names: [A-Za-z_][A-Za-z0-9_]*",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remote secrets live in the project's server-side store (AES-256-GCM at rest).
|
||||
// `--remote` switches every secrets:* verb to it; no vault password is needed.
|
||||
export interface RemoteOptions {
|
||||
remote?: boolean;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
function remoteProject(options: RemoteOptions): string {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
return requireProject(cfg, options.project);
|
||||
}
|
||||
|
||||
function remoteRow(s: ProjectSecret): string[] {
|
||||
return [s.name, s.url || "—", s.notes || "—", formatDate(s.updated_at)];
|
||||
}
|
||||
|
||||
export interface SetOptions extends VaultPasswordOptions, RemoteOptions {
|
||||
value?: string;
|
||||
url?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export async function cmdSecretsSet(name: string, options: SetOptions): Promise<void> {
|
||||
checkName(name);
|
||||
let value = options.value;
|
||||
if (value === undefined && !process.stdin.isTTY) {
|
||||
value = readFileSync(0, "utf8").replace(/\r?\n$/, "");
|
||||
}
|
||||
if (value === undefined) {
|
||||
const entered = await p.password({ message: `Value for ${name}` });
|
||||
if (p.isCancel(entered)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
value = entered;
|
||||
}
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
await SecretsService.setProjectSecret({
|
||||
projectId,
|
||||
name,
|
||||
requestBody: { value, url: options.url, notes: options.notes },
|
||||
});
|
||||
success(`Set ${name} in the project vault`);
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
const now = new Date().toISOString();
|
||||
const existing = vault.data.secrets[name];
|
||||
vault.data.secrets[name] = {
|
||||
value,
|
||||
url: options.url ?? existing?.url,
|
||||
notes: options.notes ?? existing?.notes,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`${existing ? "Updated" : "Set"} ${name}`);
|
||||
}
|
||||
|
||||
async function pickRemote(projectId: string, message: string): Promise<string> {
|
||||
const { items } = await SecretsService.listProjectSecrets({ projectId });
|
||||
if (!items.length) {
|
||||
dim("Project vault is empty — `primora secrets set NAME --remote`.");
|
||||
process.exit(0);
|
||||
}
|
||||
const picked = await p.select({
|
||||
message,
|
||||
options: items.map((s) => ({ value: s.name, label: s.name, hint: s.url || s.notes || undefined })),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
export async function cmdSecretsGet(
|
||||
name: string | undefined,
|
||||
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
name ??= await pickRemote(projectId, "Secret to reveal");
|
||||
const res = await SecretsService.revealProjectSecret({ projectId, name });
|
||||
if (isJson(options)) {
|
||||
printJson({ name, value: res.value });
|
||||
return;
|
||||
}
|
||||
process.stdout.write(res.value + "\n");
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
if (!name) {
|
||||
if (!process.stdin.isTTY) throw new CliError("Usage: primora secrets get <name>");
|
||||
const names = Object.keys(vault.data.secrets).sort();
|
||||
if (!names.length) {
|
||||
dim("Vault is empty — `primora secrets set NAME`.");
|
||||
return;
|
||||
}
|
||||
const picked = await p.select({
|
||||
message: "Secret to print",
|
||||
options: names.map((n) => ({
|
||||
value: n,
|
||||
label: n,
|
||||
hint: vault.data.secrets[n].url ?? vault.data.secrets[n].notes ?? undefined,
|
||||
})),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
name = picked;
|
||||
}
|
||||
const entry = vault.data.secrets[name];
|
||||
if (!entry) throw new CliError(`Secret "${name}" not found.`);
|
||||
if (isJson(options)) {
|
||||
printJson({
|
||||
name,
|
||||
value: entry.value,
|
||||
url: entry.url ?? null,
|
||||
notes: entry.notes ?? null,
|
||||
created_at: entry.created_at,
|
||||
updated_at: entry.updated_at,
|
||||
});
|
||||
return;
|
||||
}
|
||||
process.stdout.write(entry.value + "\n");
|
||||
}
|
||||
|
||||
export async function cmdSecretsList(
|
||||
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
const { items } = await SecretsService.listProjectSecrets({ projectId });
|
||||
if (isJson(options)) {
|
||||
printJson(items);
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
dim("Project vault is empty — `primora secrets set NAME --remote`.");
|
||||
return;
|
||||
}
|
||||
printTable(["NAME", "URL", "NOTES", "UPDATED"], items.map(remoteRow));
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
const rows = Object.entries(vault.data.secrets).sort(([a], [b]) => a.localeCompare(b));
|
||||
if (isJson(options)) {
|
||||
printJson(
|
||||
rows.map(([name, e]) => ({
|
||||
name,
|
||||
url: e.url ?? null,
|
||||
notes: e.notes ?? null,
|
||||
created_at: e.created_at,
|
||||
updated_at: e.updated_at,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
dim("Vault is empty — `primora secrets set NAME` or `primora secrets import .env`.");
|
||||
return;
|
||||
}
|
||||
printTable(
|
||||
["NAME", "URL", "UPDATED"],
|
||||
rows.map(([name, e]) => [name, e.url ?? "—", formatDate(e.updated_at)]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdSecretsRemove(
|
||||
name: string | undefined,
|
||||
options: VaultPasswordOptions & RemoteOptions & { yes?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
name ??= await pickRemote(projectId, "Secret to delete");
|
||||
if (!options.yes && process.stdin.isTTY) {
|
||||
const confirmed = await p.confirm({ message: `Delete project secret ${name}?` });
|
||||
if (p.isCancel(confirmed) || !confirmed) return;
|
||||
}
|
||||
await SecretsService.deleteProjectSecret({ projectId, name });
|
||||
success(`Deleted ${name}`);
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
if (!name) {
|
||||
if (!process.stdin.isTTY) throw new CliError("Usage: primora secrets rm <name>");
|
||||
const names = Object.keys(vault.data.secrets).sort();
|
||||
if (!names.length) {
|
||||
dim("Vault is empty.");
|
||||
return;
|
||||
}
|
||||
const picked = await p.select({
|
||||
message: "Secret to delete",
|
||||
options: names.map((n) => ({ value: n, label: n })),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
name = picked;
|
||||
}
|
||||
if (!vault.data.secrets[name]) throw new CliError(`Secret "${name}" not found.`);
|
||||
if (!options.yes && process.stdin.isTTY) {
|
||||
const confirmed = await p.confirm({ message: `Delete secret ${name}?` });
|
||||
if (p.isCancel(confirmed) || !confirmed) return;
|
||||
}
|
||||
delete vault.data.secrets[name];
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`Deleted ${name}`);
|
||||
}
|
||||
|
||||
/** Parse a .env file: KEY=value lines, optional `export`, quotes stripped. */
|
||||
export function parseEnvFile(path: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
|
||||
let val = line.slice(eq + 1).trim();
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function cmdSecretsImport(
|
||||
file: string,
|
||||
options: VaultPasswordOptions & RemoteOptions & { overwrite?: boolean },
|
||||
): Promise<void> {
|
||||
const parsed = parseEnvFile(file);
|
||||
const entries = Object.entries(parsed).filter(([k]) => NAME_RE.test(k));
|
||||
if (!entries.length) throw new CliError(`No valid KEY=value pairs in ${file}.`);
|
||||
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
const { items } = await SecretsService.listProjectSecrets({ projectId });
|
||||
const existing = new Set(items.map((s) => s.name));
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const [key, value] of entries) {
|
||||
if (existing.has(key) && !options.overwrite) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
await SecretsService.setProjectSecret({ projectId, name: key, requestBody: { value } });
|
||||
added++;
|
||||
}
|
||||
success(`Imported ${added} secret${added === 1 ? "" : "s"} into the project vault`);
|
||||
if (skipped) dim(`Skipped ${skipped} existing (pass --overwrite to replace).`);
|
||||
if (entries.length < Object.keys(parsed).length) {
|
||||
dim("Skipped keys that are not env-var-safe.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const vault = await unlockVault(options);
|
||||
const now = new Date().toISOString();
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const [key, value] of entries) {
|
||||
const existing = vault.data.secrets[key];
|
||||
if (existing && !options.overwrite) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
vault.data.secrets[key] = {
|
||||
value,
|
||||
url: existing?.url,
|
||||
notes: existing?.notes,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
added++;
|
||||
}
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`Imported ${added} secret${added === 1 ? "" : "s"} from ${file}`);
|
||||
if (skipped) dim(`Skipped ${skipped} existing (pass --overwrite to replace).`);
|
||||
if (entries.length < Object.keys(parsed).length) {
|
||||
dim("Skipped keys that are not env-var-safe.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { CliError } from "../http.js";
|
||||
import { dim, success } from "../out.js";
|
||||
import { vaultAsEnvFile } from "./inject.js";
|
||||
import { parseEnvFile } from "./secrets.js";
|
||||
import type { VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
interface StackOptions extends VaultPasswordOptions {
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
function projectDir(options: StackOptions): string {
|
||||
const dir = options.dir ?? process.cwd();
|
||||
for (const name of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) {
|
||||
if (existsSync(join(dir, name))) return dir;
|
||||
}
|
||||
throw new CliError(
|
||||
`No compose file in ${dir}.`,
|
||||
"Run from a Primora deployment directory or pass --dir.",
|
||||
);
|
||||
}
|
||||
|
||||
function compose(dir: string, args: string[], env?: NodeJS.ProcessEnv): number {
|
||||
const res = spawnSync("docker", ["compose", ...args], {
|
||||
cwd: dir,
|
||||
stdio: "inherit",
|
||||
env: env ?? process.env,
|
||||
});
|
||||
if (res.error) {
|
||||
throw new CliError(
|
||||
`Failed to run docker compose: ${res.error.message}`,
|
||||
"Docker with the Compose plugin is required.",
|
||||
);
|
||||
}
|
||||
return res.status ?? 1;
|
||||
}
|
||||
|
||||
export async function cmdStackUp(
|
||||
options: StackOptions & { vault?: boolean; force?: boolean; detach?: boolean },
|
||||
): Promise<void> {
|
||||
const dir = projectDir(options);
|
||||
const envPath = join(dir, ".env");
|
||||
|
||||
if (!options.vault) {
|
||||
const code = compose(dir, ["up", "-d"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
success("Stack is up.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Vault mode: materialize .env for the duration of compose, then remove it.
|
||||
// `env_file:` directives make compose require the file on disk — there is no
|
||||
// flag to feed it via the process environment alone.
|
||||
const secrets = await vaultAsEnvFile(options);
|
||||
const hadEnv = existsSync(envPath);
|
||||
if (hadEnv && !options.force) {
|
||||
throw new CliError(
|
||||
`${envPath} already exists.`,
|
||||
"Import it (`primora secrets import .env --overwrite`), remove it, or pass --force to swap it for the vault during this run.",
|
||||
);
|
||||
}
|
||||
const prior = hadEnv ? readFileSync(envPath) : undefined;
|
||||
// compose env_file parsing is literal — write raw KEY=value lines.
|
||||
const lines = Object.entries(secrets).map(([k, v]) => {
|
||||
if (v.includes("\n")) throw new CliError(`Secret ${k} contains a newline — cannot be an env var.`);
|
||||
return `${k}=${v}`;
|
||||
});
|
||||
try {
|
||||
writeFileSync(envPath, lines.join("\n") + "\n", { mode: 0o600 });
|
||||
const code = compose(dir, ["up", "-d"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
success("Stack is up — .env restored/removed; secrets live in the vault only.");
|
||||
} finally {
|
||||
if (prior) writeFileSync(envPath, prior, { mode: 0o600 });
|
||||
else if (existsSync(envPath)) unlinkSync(envPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdStackDown(options: StackOptions): Promise<void> {
|
||||
const dir = projectDir(options);
|
||||
const code = compose(dir, ["down"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
success("Stack stopped.");
|
||||
}
|
||||
|
||||
export async function cmdStackStatus(options: StackOptions): Promise<void> {
|
||||
const code = compose(projectDir(options), ["ps"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
}
|
||||
|
||||
export async function cmdStackLogs(
|
||||
service: string | undefined,
|
||||
options: StackOptions & { follow?: boolean; tail?: string },
|
||||
): Promise<void> {
|
||||
const args = ["logs", "--tail", options.tail ?? "200"];
|
||||
if (options.follow) args.push("--follow");
|
||||
if (service) args.push(service);
|
||||
const code = compose(projectDir(options), args);
|
||||
if (code !== 0) process.exit(code);
|
||||
}
|
||||
|
||||
export async function cmdStackPull(options: StackOptions): Promise<void> {
|
||||
const dir = projectDir(options);
|
||||
const code = compose(dir, ["pull"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
success("Images updated — run `primora stack up` to apply.");
|
||||
}
|
||||
|
||||
/** Postgres dump via `compose exec`; for a full backup (DB + storage volume)
|
||||
* use scripts/backup.sh — it covers both. ponytail: no storage tar here. */
|
||||
export async function cmdStackBackup(
|
||||
options: StackOptions & { out?: string },
|
||||
): Promise<void> {
|
||||
const dir = projectDir(options);
|
||||
const envFile = join(dir, ".env");
|
||||
const env = existsSync(envFile) ? parseEnvFile(envFile) : {};
|
||||
const user = env.POSTGRES_USER ?? "primora";
|
||||
const db = env.POSTGRES_DB ?? "primora";
|
||||
|
||||
const outDir = options.out ?? join(dir, "backups");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const outPath = join(outDir, `primora-${stamp}.dump`);
|
||||
|
||||
const res = spawnSync(
|
||||
"docker",
|
||||
["compose", "exec", "-T", "postgres", "pg_dump", "-U", user, "-Fc", db],
|
||||
{ cwd: dir, encoding: "buffer", maxBuffer: 1024 * 1024 * 1024 },
|
||||
);
|
||||
if (res.error || res.status !== 0) {
|
||||
throw new CliError(
|
||||
`pg_dump failed: ${res.error?.message ?? res.stderr?.toString().slice(0, 300) ?? `exit ${res.status}`}`,
|
||||
"Is the stack running? `primora stack status`",
|
||||
);
|
||||
}
|
||||
writeFileSync(outPath, res.stdout as Buffer, { mode: 0o600 });
|
||||
success(`Backup written to ${outPath}`);
|
||||
dim("Postgres only — scripts/backup.sh also archives the storage volume.");
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import * as p from "@clack/prompts";
|
||||
|
||||
import pc from "picocolors";
|
||||
|
||||
import { CliError } from "../http.js";
|
||||
import { dim, isJson, printJson, success, withSpinner } from "../out.js";
|
||||
import {
|
||||
clearSession,
|
||||
createVault,
|
||||
loadSessionKey,
|
||||
openVault,
|
||||
openVaultWithKey,
|
||||
saveSessionKey,
|
||||
sessionExpiry,
|
||||
vaultExists,
|
||||
vaultHeader,
|
||||
vaultPath,
|
||||
type OpenVault,
|
||||
} from "../vault.js";
|
||||
|
||||
export interface VaultPasswordOptions {
|
||||
passwordFile?: string;
|
||||
}
|
||||
|
||||
async function promptPassword(confirm: boolean): Promise<Uint8Array> {
|
||||
const entered = await p.password({ message: "Vault password" });
|
||||
if (p.isCancel(entered)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
if (confirm) {
|
||||
const again = await p.password({ message: "Confirm vault password" });
|
||||
if (p.isCancel(again)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
if (again !== entered) throw new CliError("Passwords do not match.");
|
||||
}
|
||||
return new TextEncoder().encode(entered);
|
||||
}
|
||||
|
||||
async function readPassword(options: VaultPasswordOptions): Promise<Uint8Array | undefined> {
|
||||
const env = process.env.PRIMORA_VAULT_PASSWORD;
|
||||
if (env) return new TextEncoder().encode(env);
|
||||
if (options.passwordFile) {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const pw = readFileSync(options.passwordFile, "utf8").replace(/\r?\n$/, "");
|
||||
return new TextEncoder().encode(pw);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the vault: live session key → PRIMORA_VAULT_PASSWORD → --password-file →
|
||||
* hidden prompt. The session is how agents and scripts use the vault without
|
||||
* ever seeing the password; everything else still proves the password.
|
||||
*/
|
||||
export async function unlockVault(options: VaultPasswordOptions): Promise<OpenVault> {
|
||||
const session = loadSessionKey();
|
||||
if (session) return openVaultWithKey(session);
|
||||
|
||||
const password = await readPassword(options);
|
||||
if (password) return withSpinner("Unlocking vault…", async () => openVault(password));
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new CliError(
|
||||
"Vault is locked.",
|
||||
"Run `primora vault unlock`, or set PRIMORA_VAULT_PASSWORD / pass --password-file.",
|
||||
);
|
||||
}
|
||||
const prompted = await promptPassword(false);
|
||||
return withSpinner("Unlocking vault…", async () => openVault(prompted));
|
||||
}
|
||||
|
||||
export async function cmdVaultInit(options: VaultPasswordOptions): Promise<void> {
|
||||
let password = await readPassword(options);
|
||||
if (!password) {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new CliError(
|
||||
"Vault password required.",
|
||||
"Set PRIMORA_VAULT_PASSWORD or pass --password-file in non-interactive mode.",
|
||||
);
|
||||
}
|
||||
password = await promptPassword(true);
|
||||
}
|
||||
await withSpinner("Creating vault…", async () => createVault(password));
|
||||
success(`Vault created at ${vaultPath()}`);
|
||||
dim("Secrets are encrypted with Argon2id + XChaCha20-Poly1305. The password is not recoverable.");
|
||||
}
|
||||
|
||||
export async function cmdVaultUnlock(
|
||||
options: VaultPasswordOptions & { ttl?: string },
|
||||
): Promise<void> {
|
||||
const session = loadSessionKey();
|
||||
if (session) {
|
||||
const exp = sessionExpiry();
|
||||
dim(`Already unlocked — expires ${exp ? new Date(exp * 1000).toISOString() : "soon"}.`);
|
||||
return;
|
||||
}
|
||||
const password =
|
||||
(await readPassword(options)) ??
|
||||
(process.stdin.isTTY
|
||||
? await promptPassword(false)
|
||||
: (() => {
|
||||
throw new CliError(
|
||||
"Vault password required.",
|
||||
"Set PRIMORA_VAULT_PASSWORD or pass --password-file.",
|
||||
);
|
||||
})());
|
||||
const vault = await withSpinner("Unlocking vault…", async () => openVault(password)); // proves the password before stashing the key
|
||||
const ttl = saveSessionKey(vault.key, Number(options.ttl ?? 900));
|
||||
success(`Vault unlocked for ${ttl}s — secrets usable without the password until expiry.`);
|
||||
dim("`primora vault lock` revokes immediately.");
|
||||
}
|
||||
|
||||
export async function cmdVaultLock(): Promise<void> {
|
||||
if (clearSession()) success("Vault locked — session revoked.");
|
||||
else dim("No active session.");
|
||||
}
|
||||
|
||||
export async function cmdVaultStatus(options: { json?: boolean }): Promise<void> {
|
||||
const exists = vaultExists();
|
||||
const header = exists ? vaultHeader() : undefined;
|
||||
const exp = sessionExpiry();
|
||||
const unlocked = exp !== undefined;
|
||||
let count: number | undefined;
|
||||
if (unlocked) {
|
||||
try {
|
||||
count = Object.keys(openVaultWithKey(loadSessionKey()!).data.secrets).length;
|
||||
} catch {
|
||||
// session key no longer matches (vault re-created) — report locked
|
||||
}
|
||||
}
|
||||
if (isJson(options)) {
|
||||
printJson({
|
||||
path: vaultPath(),
|
||||
exists,
|
||||
unlocked,
|
||||
expires_at: exp ?? null,
|
||||
secrets: count ?? null,
|
||||
kdf: header ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!exists) {
|
||||
dim(`No vault at ${vaultPath()} — run \`primora vault init\`.`);
|
||||
return;
|
||||
}
|
||||
const remaining = exp ? Math.max(0, exp - Math.floor(Date.now() / 1000)) : 0;
|
||||
const mins = Math.floor(remaining / 60);
|
||||
const state = unlocked
|
||||
? pc.green("● unlocked") +
|
||||
pc.dim(` — ${mins ? `${mins}m ` : ""}${remaining % 60}s remaining`)
|
||||
: pc.dim("● locked");
|
||||
process.stdout.write(`vault ${state}\n\n`);
|
||||
const rows: [string, string][] = [
|
||||
["path", vaultPath()],
|
||||
["kdf", `argon2id · ${header!.m} KiB · ${header!.t} passes · ${header!.p} lanes`],
|
||||
];
|
||||
if (unlocked) rows.push(["secrets", String(count ?? "?")]);
|
||||
const width = Math.max(...rows.map(([k]) => k.length));
|
||||
for (const [k, v] of rows) {
|
||||
process.stdout.write(`${pc.dim(k.padEnd(width))} ${v}\n`);
|
||||
}
|
||||
if (!unlocked) dim(`\n\`primora vault unlock\` opens a session for agents and scripts.`);
|
||||
}
|
||||
@@ -60,6 +60,8 @@ export function configureClient(cfg: CliConfig): void {
|
||||
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
|
||||
OpenAPI.HEADERS = undefined;
|
||||
OpenAPI.TOKEN = undefined;
|
||||
// encodeURI leaves ?#& intact in path params — smuggles query segments.
|
||||
OpenAPI.ENCODE_PATH = encodeURIComponent;
|
||||
|
||||
if (cfg.auth?.type === "apiKey") {
|
||||
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
|
||||
|
||||
+35
-1
@@ -1,3 +1,4 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
|
||||
export function isJson(options: { json?: boolean }): boolean {
|
||||
@@ -16,9 +17,27 @@ export function dim(message: string): void {
|
||||
process.stdout.write(pc.dim(message) + "\n");
|
||||
}
|
||||
|
||||
/** Destructive-action gate — requires --yes in scripts, prompts on a TTY. */
|
||||
export async function confirmOrAbort(message: string, yes?: boolean): Promise<void> {
|
||||
if (yes) return;
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error(`${message} — pass --yes to confirm in non-interactive mode`);
|
||||
}
|
||||
const answer = await p.confirm({ message });
|
||||
if (p.isCancel(answer) || !answer) {
|
||||
p.cancel("Aborted.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function fail(error: unknown): never {
|
||||
const err = error as { message?: string; hint?: string; status?: number; body?: unknown };
|
||||
const err = error as { message?: string; hint?: string; status?: number; body?: unknown; cause?: { code?: string } };
|
||||
let message = err?.message ?? "unknown error";
|
||||
// undici TypeError "fetch failed" — the useful part is the cause code.
|
||||
if (message === "fetch failed" || message.startsWith("fetch failed")) {
|
||||
const code = err?.cause?.code;
|
||||
message = `could not reach the instance${code ? ` (${code})` : ""} — check \`primora context\` and that it is running`;
|
||||
}
|
||||
// generated client throws ApiError with a parsed body
|
||||
const body = err?.body as { error?: { message?: string } } | undefined;
|
||||
if (body?.error?.message) message = body.error.message;
|
||||
@@ -28,6 +47,21 @@ export function fail(error: unknown): never {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Spinner for multi-hundred-ms work (Argon2 derives). Silent when piped. */
|
||||
export async function withSpinner<T>(message: string, work: () => Promise<T>): Promise<T> {
|
||||
if (!process.stdout.isTTY) return work();
|
||||
const s = p.spinner();
|
||||
s.start(message);
|
||||
try {
|
||||
const result = await work();
|
||||
s.stop(message);
|
||||
return result;
|
||||
} catch (error) {
|
||||
s.stop(pc.red("failed"));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal column printer — left-aligned, two-space gutter. */
|
||||
export function printTable(headers: string[], rows: string[][]): void {
|
||||
const all = headers.length ? [headers, ...rows] : rows;
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// Local encrypted vault for CLI-held secrets (API keys, deployment .env values).
|
||||
// Clean-room format — inspired by arca-core's design goals, not its code (AGPL).
|
||||
//
|
||||
// File layout (all multi-byte ints little-endian):
|
||||
// 8B magic "PRMVLT01"
|
||||
// 1B kdf id (0x01 = argon2id)
|
||||
// 4B m (KiB) 4B t 4B p
|
||||
// 1B aead id (0x01 = xchacha20poly1305)
|
||||
// 16B salt 24B nonce
|
||||
// … ciphertext+tag of JSON payload; the full header is the AEAD AAD,
|
||||
// so no parameter can be altered without failing decryption.
|
||||
//
|
||||
// Session: `vault unlock` writes vault.session (0600, TTL'd) holding the
|
||||
// derived key, so agents/scripts use the vault without the password.
|
||||
// `vault lock` or expiry removes it. The session file is the same-user risk
|
||||
// equivalent of a plaintext .env for its lifetime — bounded by the TTL.
|
||||
//
|
||||
// ponytail: single credential slot, no duress/decoy, no Shamir — those are
|
||||
// coercion-resistance features, not a CLI secrets problem.
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
|
||||
import { argon2id } from "@noble/hashes/argon2.js";
|
||||
|
||||
import { CliError } from "./http.js";
|
||||
|
||||
export interface SecretEntry {
|
||||
value: string;
|
||||
url?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface VaultData {
|
||||
v: 1;
|
||||
secrets: Record<string, SecretEntry>;
|
||||
}
|
||||
|
||||
const MAGIC = "PRMVLT01";
|
||||
const KDF_ARGON2ID = 0x01;
|
||||
const AEAD_XCHACHA = 0x01;
|
||||
const HEADER_LEN = 8 + 1 + 4 + 4 + 4 + 1 + 16 + 24; // 62
|
||||
|
||||
// Floors, not targets — matching argon2id's current recommended memory cost.
|
||||
const KDF = { m: 65536, t: 3, p: 4 } as const;
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
|
||||
export function configDir(): string {
|
||||
return (
|
||||
process.env.PRIMORA_CONFIG_DIR ??
|
||||
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "primora")
|
||||
);
|
||||
}
|
||||
|
||||
export function vaultPath(): string {
|
||||
return process.env.PRIMORA_VAULT ?? join(configDir(), "vault.bin");
|
||||
}
|
||||
|
||||
export function vaultExists(): boolean {
|
||||
return existsSync(vaultPath());
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* session */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const SESSION_MAX_TTL = 86400; // 24 h ceiling
|
||||
|
||||
interface SessionFile {
|
||||
key: string; // base64 data key
|
||||
exp: number; // epoch seconds
|
||||
}
|
||||
|
||||
function sessionPath(): string {
|
||||
return process.env.PRIMORA_VAULT_SESSION ?? join(configDir(), "vault.session");
|
||||
}
|
||||
|
||||
/** Session key if one exists and is unexpired; expired files are removed. */
|
||||
export function loadSessionKey(): Uint8Array | undefined {
|
||||
const path = sessionPath();
|
||||
if (!existsSync(path)) return undefined;
|
||||
try {
|
||||
const s = JSON.parse(readFileSync(path, "utf8")) as SessionFile;
|
||||
if (typeof s.key !== "string" || typeof s.exp !== "number") throw new Error("shape");
|
||||
if (s.exp <= Date.now() / 1000) {
|
||||
unlinkSync(path);
|
||||
return undefined;
|
||||
}
|
||||
const key = Buffer.from(s.key, "base64");
|
||||
if (key.length !== 32) throw new Error("bad key length");
|
||||
return new Uint8Array(key);
|
||||
} catch {
|
||||
unlinkSync(path);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSessionKey(key: Uint8Array, ttlSeconds: number): number {
|
||||
const ttl = Math.min(Math.max(60, ttlSeconds), SESSION_MAX_TTL);
|
||||
const exp = Math.floor(Date.now() / 1000) + ttl;
|
||||
mkdirSync(dirname(sessionPath()), { recursive: true });
|
||||
writeFileSync(sessionPath(), JSON.stringify({ key: Buffer.from(key).toString("base64"), exp }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
return ttl;
|
||||
}
|
||||
|
||||
export function clearSession(): boolean {
|
||||
const path = sessionPath();
|
||||
if (!existsSync(path)) return false;
|
||||
unlinkSync(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function sessionExpiry(): number | undefined {
|
||||
const path = sessionPath();
|
||||
if (!existsSync(path)) return undefined;
|
||||
try {
|
||||
const s = JSON.parse(readFileSync(path, "utf8")) as SessionFile;
|
||||
return s.exp > Date.now() / 1000 ? s.exp : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* format */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Header fields readable without the password (for `vault status`). */
|
||||
export function vaultHeader(): { m: number; t: number; p: number } | undefined {
|
||||
const path = vaultPath();
|
||||
if (!existsSync(path)) return undefined;
|
||||
const buf = readFileSync(path);
|
||||
if (buf.length < HEADER_LEN || buf.subarray(0, 8).toString("latin1") !== MAGIC) {
|
||||
throw new CliError(`Not a primora vault: ${path}`);
|
||||
}
|
||||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
return { m: dv.getUint32(9, true), t: dv.getUint32(13, true), p: dv.getUint32(17, true) };
|
||||
}
|
||||
|
||||
interface ParsedFile {
|
||||
buf: Buffer;
|
||||
header: Buffer;
|
||||
salt: Buffer;
|
||||
nonce: Buffer;
|
||||
params: { m: number; t: number; p: number };
|
||||
}
|
||||
|
||||
function parseFile(): ParsedFile {
|
||||
const path = vaultPath();
|
||||
if (!existsSync(path)) {
|
||||
throw new CliError("No vault found.", "Run `primora vault init` first.");
|
||||
}
|
||||
const buf = readFileSync(path);
|
||||
if (buf.length < HEADER_LEN + 16 || buf.subarray(0, 8).toString("latin1") !== MAGIC) {
|
||||
throw new CliError(`Not a primora vault: ${path}`);
|
||||
}
|
||||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
if (dv.getUint8(8) !== KDF_ARGON2ID || dv.getUint8(21) !== AEAD_XCHACHA) {
|
||||
throw new CliError("Unsupported vault parameters — newer format version?");
|
||||
}
|
||||
const params = { m: dv.getUint32(9, true), t: dv.getUint32(13, true), p: dv.getUint32(17, true) };
|
||||
if (params.m < 8192 || params.t < 1 || params.p < 1) {
|
||||
throw new CliError("Vault KDF parameters below floor — file is suspect.");
|
||||
}
|
||||
return {
|
||||
buf,
|
||||
header: buf.subarray(0, HEADER_LEN),
|
||||
salt: buf.subarray(22, 38),
|
||||
nonce: buf.subarray(38, 62),
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenVault {
|
||||
data: VaultData;
|
||||
key: Uint8Array;
|
||||
salt: Uint8Array;
|
||||
params: { m: number; t: number; p: number };
|
||||
}
|
||||
|
||||
function decrypt(f: ParsedFile, key: Uint8Array): VaultData {
|
||||
try {
|
||||
const plain = xchacha20poly1305(key, f.nonce, f.header).decrypt(f.buf.subarray(HEADER_LEN));
|
||||
const data = JSON.parse(dec.decode(plain)) as VaultData;
|
||||
if (data.v !== 1 || typeof data.secrets !== "object") throw new Error("shape");
|
||||
return data;
|
||||
} catch {
|
||||
throw new CliError("Wrong password or corrupt vault.");
|
||||
}
|
||||
}
|
||||
|
||||
export function openVault(password: Uint8Array): OpenVault {
|
||||
const f = parseFile();
|
||||
const key = argon2id(password, f.salt, { ...f.params, dkLen: 32 });
|
||||
return { data: decrypt(f, key), key, salt: f.salt, params: f.params };
|
||||
}
|
||||
|
||||
/** Open with a session key — skips Argon2 entirely. */
|
||||
export function openVaultWithKey(key: Uint8Array): OpenVault {
|
||||
const f = parseFile();
|
||||
return { data: decrypt(f, key), key, salt: f.salt, params: f.params };
|
||||
}
|
||||
|
||||
/** Re-encrypt with the same salt/params and a fresh nonce. Atomic via tmp+rename. */
|
||||
export function saveVault(open: OpenVault): void {
|
||||
const path = vaultPath();
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(24));
|
||||
|
||||
const header = new Uint8Array(HEADER_LEN);
|
||||
header.set(enc.encode(MAGIC), 0);
|
||||
const dv = new DataView(header.buffer);
|
||||
dv.setUint8(8, KDF_ARGON2ID);
|
||||
dv.setUint32(9, open.params.m, true);
|
||||
dv.setUint32(13, open.params.t, true);
|
||||
dv.setUint32(17, open.params.p, true);
|
||||
dv.setUint8(21, AEAD_XCHACHA);
|
||||
header.set(open.salt, 22);
|
||||
header.set(nonce, 38);
|
||||
|
||||
const plain = enc.encode(JSON.stringify(open.data));
|
||||
const ct = xchacha20poly1305(open.key, nonce, header).encrypt(plain);
|
||||
|
||||
const tmp = `${path}.${process.pid}.tmp`;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(tmp, Buffer.concat([header, Buffer.from(ct)]), { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
export function createVault(password: Uint8Array): void {
|
||||
const path = vaultPath();
|
||||
if (existsSync(path)) {
|
||||
throw new CliError(
|
||||
`Vault already exists: ${path}`,
|
||||
"Use `primora secrets:*` — or remove the file to start over.",
|
||||
);
|
||||
}
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = argon2id(password, salt, { ...KDF, dkLen: 32 });
|
||||
saveVault({ data: { v: 1, secrets: {} }, key, salt, params: KDF });
|
||||
}
|
||||
@@ -28,4 +28,37 @@ npm run bundle --workspace @primora/desktop # release bundles → src-tauri/ta
|
||||
to be a Primora deployment, use the tray's Change Server.
|
||||
- macOS blocks plain-HTTP loads to non-localhost hosts (ATS). Use HTTPS, or
|
||||
`http://localhost` for a local deployment.
|
||||
|
||||
## Secrets Vault
|
||||
|
||||
The tray menu's **Secrets Vault…** opens a local window (`ui/vault.html`) for
|
||||
the CLI's encrypted vault — init, timed unlock/lock, list with copy-to-clipboard,
|
||||
add/delete, `.env` import. Values are never displayed; copy goes straight to the
|
||||
clipboard.
|
||||
|
||||
The desktop app does not reimplement the vault: IPC commands shell out to the
|
||||
`primora` binary on PATH (`PRIMORA_CLI` overrides the binary path). One vault
|
||||
file, one crypto implementation — terminal and desktop always agree. Requires
|
||||
`npm i -g @primora/cli`; without it the window shows install instructions.
|
||||
|
||||
Injection and the credential broker stay CLI-only (`primora inject`,
|
||||
`primora agent`).
|
||||
- Deep links / push notifications: deliberately omitted — nothing needs them yet.
|
||||
|
||||
## Auto-updates
|
||||
|
||||
`tauri-plugin-updater` checks `releases/latest/download/latest.json` on the
|
||||
GitHub repo when the connect screen loads; if a newer signed release exists
|
||||
it offers "Update & restart" — download, minisign verification, install,
|
||||
relaunch. No update manifest or signature failure = silent no-op; unsigned
|
||||
artifacts can never install.
|
||||
|
||||
Release side (already wired in `.github/workflows/release.yml`): the desktop
|
||||
job signs updater artifacts with `TAURI_SIGNING_PRIVATE_KEY` (+ optional
|
||||
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`) repo secrets, uploads the `.sig`
|
||||
files, and the release job assembles `latest.json` from them. Without the
|
||||
secret the build still succeeds but emits no signatures — and no manifest.
|
||||
|
||||
The matching public key is embedded in `tauri.conf.json`. The private key
|
||||
lives only in repo secrets and on the maintainer's machine — regenerate with
|
||||
`npx tauri signer generate` and update both if it is ever lost.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@primora/desktop",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"description": "Primora desktop shell — thin Tauri client for a self-hosted deployment",
|
||||
"scripts": {
|
||||
|
||||
Generated
+457
-8
@@ -47,6 +47,15 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -506,6 +515,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -529,7 +548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -542,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -713,6 +732,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -996,6 +1026,16 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
@@ -1254,6 +1294,17 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1530,6 +1581,21 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1548,9 +1614,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1847,6 +1915,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.20",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -2036,6 +2134,12 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2288,6 +2392,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2303,6 +2408,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2366,6 +2483,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2382,6 +2505,20 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -2615,7 +2752,9 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2804,15 +2943,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2824,6 +2968,20 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -2852,6 +3010,79 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -2867,6 +3098,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -2924,6 +3164,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.36.1"
|
||||
@@ -3145,6 +3408,22 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
@@ -3257,6 +3536,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.8"
|
||||
@@ -3320,6 +3605,27 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3341,7 +3647,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -3351,7 +3657,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -3384,6 +3690,17 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -3408,7 +3725,7 @@ dependencies = [
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"image",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -3504,6 +3821,32 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.4"
|
||||
@@ -3520,6 +3863,39 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -3530,7 +3906,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -3553,7 +3929,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -3742,6 +4118,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
@@ -4055,6 +4441,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4298,6 +4690,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -4483,6 +4884,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -4528,6 +4940,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -4823,7 +5244,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -4870,6 +5291,16 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
@@ -4984,6 +5415,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
@@ -5017,6 +5454,18 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.14.2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
||||
@@ -15,5 +15,7 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -2,6 +2,6 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
"windows": ["main", "vault"],
|
||||
"permissions": ["core:default", "updater:default", "process:default"]
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Manager, WindowEvent,
|
||||
AppHandle, Listener, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
||||
};
|
||||
|
||||
/// Persisted shell state — one field, the deployment to load.
|
||||
@@ -67,24 +70,165 @@ fn show_main(app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_vault(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("vault") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
return;
|
||||
}
|
||||
let _ = WebviewWindowBuilder::new(app, "vault", WebviewUrl::App("vault.html".into()))
|
||||
.title("Primora — Secrets Vault")
|
||||
.inner_size(780.0, 620.0)
|
||||
.min_inner_size(560.0, 420.0)
|
||||
.center()
|
||||
.build();
|
||||
}
|
||||
|
||||
/// The vault lives in the CLI — the desktop proxies the `primora` binary so
|
||||
/// both surfaces operate on the same vault file, one crypto implementation.
|
||||
/// Passwords travel via env, secret values via stdin; never via argv.
|
||||
fn run_cli(args: &[&str], stdin_data: Option<&str>, password: Option<&str>) -> Result<String, String> {
|
||||
let bin = std::env::var("PRIMORA_CLI").unwrap_or_else(|_| "primora".into());
|
||||
let mut cmd = Command::new(&bin);
|
||||
cmd.args(args)
|
||||
.stdin(if stdin_data.is_some() { Stdio::piped() } else { Stdio::null() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(pw) = password {
|
||||
cmd.env("PRIMORA_VAULT_PASSWORD", pw);
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(|_| {
|
||||
"primora CLI not found on PATH — install it: npm i -g @primora/cli".to_string()
|
||||
})?;
|
||||
if let Some(data) = stdin_data {
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.ok_or("failed to open child stdin")?
|
||||
.write_all(data.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let out = child.wait_with_output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_probe() -> bool {
|
||||
let bin = std::env::var("PRIMORA_CLI").unwrap_or_else(|_| "primora".into());
|
||||
Command::new(bin)
|
||||
.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_status() -> Result<Value, String> {
|
||||
let out = run_cli(&["vault", "status", "--json"], None, None)?;
|
||||
serde_json::from_str(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_secrets() -> Result<Value, String> {
|
||||
let out = run_cli(&["secrets", "list", "--json"], None, None)?;
|
||||
serde_json::from_str(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_reveal(name: String) -> Result<String, String> {
|
||||
run_cli(&["secrets", "get", &name], None, None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_init(password: String) -> Result<(), String> {
|
||||
run_cli(&["vault", "init"], None, Some(&password)).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_unlock(password: String, ttl: u32) -> Result<(), String> {
|
||||
let ttl = ttl.clamp(60, 86400).to_string();
|
||||
run_cli(&["vault", "unlock", "--ttl", &ttl], None, Some(&password)).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_lock() -> Result<(), String> {
|
||||
run_cli(&["vault", "lock"], None, None).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_set(
|
||||
name: String,
|
||||
value: String,
|
||||
url: Option<String>,
|
||||
notes: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut args = vec!["secrets", "set", name.as_str()];
|
||||
if let Some(u) = url.as_deref().filter(|s| !s.is_empty()) {
|
||||
args.extend(["--url", u]);
|
||||
}
|
||||
if let Some(n) = notes.as_deref().filter(|s| !s.is_empty()) {
|
||||
args.extend(["--notes", n]);
|
||||
}
|
||||
run_cli(&args, Some(&value), None).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_rm(name: String) -> Result<(), String> {
|
||||
run_cli(&["secrets", "rm", &name, "--yes"], None, None).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_import(path: String, overwrite: bool) -> Result<String, String> {
|
||||
let mut args = vec!["secrets", "import", path.as_str()];
|
||||
if overwrite {
|
||||
args.push("--overwrite");
|
||||
}
|
||||
run_cli(&args, None, None)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
show_main(app);
|
||||
}))
|
||||
.invoke_handler(tauri::generate_handler![get_server_url, set_server_url])
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_server_url,
|
||||
set_server_url,
|
||||
vault_probe,
|
||||
vault_status,
|
||||
vault_secrets,
|
||||
vault_reveal,
|
||||
vault_init,
|
||||
vault_unlock,
|
||||
vault_lock,
|
||||
vault_set,
|
||||
vault_rm,
|
||||
vault_import,
|
||||
])
|
||||
.setup(|app| {
|
||||
let show = MenuItem::with_id(app, "show", "Show Primora", true, None::<&str>)?;
|
||||
let vault_item = MenuItem::with_id(app, "vault", "Secrets Vault…", true, None::<&str>)?;
|
||||
let change = MenuItem::with_id(app, "change_server", "Change Server…", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show, &change, &quit])?;
|
||||
let menu = Menu::with_items(app, &[&show, &vault_item, &change, &quit])?;
|
||||
|
||||
let mut tray = TrayIconBuilder::new()
|
||||
.menu(&menu)
|
||||
.tooltip("Primora")
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"show" => show_main(app),
|
||||
"vault" => open_vault(app),
|
||||
"change_server" => {
|
||||
// Clearing the URL and restarting lands back on the connect screen.
|
||||
let _ = save_config(app, &DesktopConfig { server_url: None });
|
||||
@@ -108,6 +252,10 @@ pub fn run() {
|
||||
}
|
||||
let _tray = tray.build(app)?;
|
||||
|
||||
// The connect screen links here — "open the local Secrets Vault".
|
||||
let handle = app.handle().clone();
|
||||
app.listen("open-vault", move |_| open_vault(&handle));
|
||||
|
||||
// Close hides to the tray instead of quitting — Quit is on the menu.
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let handle = window.clone();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Primora",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"identifier": "dev.tdvorak.primora",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -23,9 +23,18 @@
|
||||
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https: http:; img-src 'self' data:; style-src 'self'"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVERjFDMzI1MURGMTlBNDAKUldSQW12RWRKY1B4WFpQN2N0RjdCemllN1ptcDhObFJQMVdHL3pLTXMzNEN6TlJoMEZhQUhDNG4K",
|
||||
"endpoints": [
|
||||
"https://github.com/Dvorinka/Primora/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Primora desktop client",
|
||||
"longDescription": "Thin desktop shell for a self-hosted Primora deployment. Renders the console served by your own server.",
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
</form>
|
||||
<p id="err" class="err" hidden></p>
|
||||
<p class="hint">The desktop app renders the console served by your own server. Nothing is hosted by us.</p>
|
||||
<p class="hint"><a href="#" id="vault-link" class="vlink">Open the local Secrets Vault</a></p>
|
||||
<div id="update" hidden>
|
||||
<p class="hint" id="update-text"></p>
|
||||
<button type="button" id="update-install">Update & restart</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
@@ -43,14 +48,59 @@
|
||||
button.textContent = "Connect";
|
||||
};
|
||||
|
||||
// Updater — check before auto-connecting: a saved URL redirects
|
||||
// immediately, so the check must resolve first or it never shows.
|
||||
// Bounded at 5s; failures are silent (dev builds, offline, no
|
||||
// manifest). Unsigned artifacts can never install — minisign is
|
||||
// enforced by the plugin against the pubkey baked into the bundle.
|
||||
const updateP = (tauri?.updater && tauri?.process)
|
||||
? Promise.race([
|
||||
tauri.updater.check().catch(() => null),
|
||||
new Promise((r) => setTimeout(() => r(null), 5000)),
|
||||
])
|
||||
: Promise.resolve(null);
|
||||
|
||||
if (!tauri) {
|
||||
fail("This page is meant to run inside the Primora desktop app.");
|
||||
} else {
|
||||
// A saved deployment URL means "skip the form" on every launch.
|
||||
const saved = await tauri.core.invoke("get_server_url").catch(() => null);
|
||||
if (saved) location.replace(saved);
|
||||
// A saved deployment URL means "skip the form" on every launch —
|
||||
// unless an update is waiting, which wins the redirect.
|
||||
const update = await updateP;
|
||||
if (update) {
|
||||
const box = document.getElementById("update");
|
||||
const text = document.getElementById("update-text");
|
||||
const install = document.getElementById("update-install");
|
||||
text.textContent = `Update available — v${update.version} (you have ${update.currentVersion}).`;
|
||||
box.hidden = false;
|
||||
install.addEventListener("click", async () => {
|
||||
install.disabled = true;
|
||||
install.textContent = "Downloading…";
|
||||
try {
|
||||
await update.downloadAndInstall();
|
||||
await tauri.process.relaunch();
|
||||
} catch (e) {
|
||||
install.disabled = false;
|
||||
install.textContent = "Update & restart";
|
||||
text.textContent = `Update failed: ${e}`;
|
||||
}
|
||||
});
|
||||
// "Not now" continues to the saved server.
|
||||
const later = document.createElement("button");
|
||||
later.type = "button";
|
||||
later.textContent = "Not now";
|
||||
later.style.marginLeft = "0.5rem";
|
||||
later.addEventListener("click", () => { const saved = _savedUrl; if (saved) location.replace(saved); });
|
||||
box.appendChild(later);
|
||||
}
|
||||
const _savedUrl = await tauri.core.invoke("get_server_url").catch(() => null);
|
||||
if (_savedUrl && !update) location.replace(_savedUrl);
|
||||
}
|
||||
|
||||
document.getElementById("vault-link").addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
tauri?.event.emit("open-vault");
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
err.hidden = true;
|
||||
|
||||
@@ -99,3 +99,222 @@ button:disabled {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---------- secrets vault window ---------- */
|
||||
|
||||
body.vault {
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #26292f;
|
||||
background: #101216;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar-sub {
|
||||
font-size: 0.72rem;
|
||||
color: #6d6d76;
|
||||
font-family: ui-monospace, monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: #6d6d76;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid #26292f;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
color: #34d399;
|
||||
border-color: #1e3a2f;
|
||||
}
|
||||
|
||||
.pill.warn {
|
||||
color: #9a9aa3;
|
||||
}
|
||||
|
||||
.view {
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.vcard {
|
||||
background: #14161b;
|
||||
border: 1px solid #26292f;
|
||||
border-radius: 14px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.vcard h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.vcard select {
|
||||
padding: 0.65rem 0.8rem;
|
||||
background: #0b0d11;
|
||||
border: 1px solid #2e3239;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.vtable th {
|
||||
text-align: left;
|
||||
color: #6d6d76;
|
||||
font-weight: 500;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid #26292f;
|
||||
}
|
||||
|
||||
.vtable td {
|
||||
padding: 0.55rem 0.5rem;
|
||||
border-bottom: 1px solid #1e2025;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.vtable tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.dimcell {
|
||||
color: #9a9aa3;
|
||||
max-width: 12rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid #2e3239;
|
||||
color: #c9c9cf;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ghost:hover {
|
||||
border-color: #f4693a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ghost.sm {
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.danger:hover {
|
||||
border-color: #ef6b6b;
|
||||
color: #ef6b6b;
|
||||
}
|
||||
|
||||
.vform {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vform input {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vform input.grow {
|
||||
flex: 3 1 14rem;
|
||||
}
|
||||
|
||||
.vform button {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
.codeblock {
|
||||
background: #0b0d11;
|
||||
border: 1px solid #26292f;
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.82rem;
|
||||
color: #e7e7ea;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.vlink {
|
||||
color: #f4693a;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.vlink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: #6d6d76;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Primora — Secrets Vault</title>
|
||||
<link rel="icon" type="image/svg+xml" href="icon.svg" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="vault">
|
||||
<header class="bar">
|
||||
<div class="brand">
|
||||
<img src="icon.svg" alt="" />
|
||||
<div>
|
||||
<h1>Secrets Vault</h1>
|
||||
<p id="path" class="bar-sub"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-right">
|
||||
<span id="pill" class="pill"></span>
|
||||
<button id="lock-btn" class="ghost" hidden>Lock</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="view" class="view">
|
||||
<p class="dim">Loading…</p>
|
||||
</main>
|
||||
<script type="module" src="vault.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,247 @@
|
||||
const tauri = window.__TAURI__;
|
||||
const view = document.getElementById("view");
|
||||
const pill = document.getElementById("pill");
|
||||
const lockBtn = document.getElementById("lock-btn");
|
||||
const pathEl = document.getElementById("path");
|
||||
|
||||
let countdownTimer = null;
|
||||
|
||||
const el = (tag, attrs = {}, ...children) => {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === "class") node.className = v;
|
||||
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
|
||||
else if (v !== null && v !== undefined && v !== false) node.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children) node.append(c);
|
||||
return node;
|
||||
};
|
||||
|
||||
const errBox = (msg) => el("p", { class: "err" }, msg);
|
||||
const dimNote = (msg) => el("p", { class: "hint" }, msg);
|
||||
|
||||
function setPill(state, expiresAt) {
|
||||
clearInterval(countdownTimer);
|
||||
pill.className = "pill";
|
||||
lockBtn.hidden = true;
|
||||
if (state === "unlocked" && expiresAt) {
|
||||
const tick = () => {
|
||||
const left = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
|
||||
const m = Math.floor(left / 60);
|
||||
const s = left % 60;
|
||||
pill.textContent = `● unlocked — ${m}m ${String(s).padStart(2, "0")}s`;
|
||||
if (!left) refresh();
|
||||
};
|
||||
pill.classList.add("ok");
|
||||
tick();
|
||||
countdownTimer = setInterval(tick, 1000);
|
||||
lockBtn.hidden = false;
|
||||
} else {
|
||||
pill.textContent = state === "none" ? "no vault" : "● locked";
|
||||
if (state !== "none") pill.classList.add("warn");
|
||||
}
|
||||
}
|
||||
|
||||
function card(title, sub, ...children) {
|
||||
return el("section", { class: "vcard" },
|
||||
el("h2", {}, title),
|
||||
sub ? el("p", { class: "sub" }, sub) : "",
|
||||
...children);
|
||||
}
|
||||
|
||||
function busy(btn, label) {
|
||||
btn.disabled = true;
|
||||
btn.dataset.label = btn.textContent;
|
||||
btn.textContent = label;
|
||||
}
|
||||
const unbusy = (btn) => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.label || btn.textContent;
|
||||
};
|
||||
|
||||
/* ---------- views ---------- */
|
||||
|
||||
function renderMissing() {
|
||||
setPill("none");
|
||||
pathEl.textContent = "";
|
||||
view.replaceChildren(card(
|
||||
"CLI required",
|
||||
"The vault lives in the primora CLI — the desktop app drives the same vault file, so terminal and desktop always agree.",
|
||||
el("pre", { class: "codeblock" }, "npm i -g @primora/cli\nprimora vault init"),
|
||||
dimNote("Reopen this window after installing.")
|
||||
));
|
||||
}
|
||||
|
||||
function renderInit() {
|
||||
setPill("none");
|
||||
const pw = el("input", { type: "password", placeholder: "Vault password", autocomplete: "new-password" });
|
||||
const pw2 = el("input", { type: "password", placeholder: "Confirm password", autocomplete: "new-password" });
|
||||
const err = el("p", { class: "err", hidden: true });
|
||||
const btn = el("button", {
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
if (pw.value.length < 8) { err.textContent = "Use at least 8 characters."; err.hidden = false; return; }
|
||||
if (pw.value !== pw2.value) { err.textContent = "Passwords do not match."; err.hidden = false; return; }
|
||||
busy(btn, "Creating…");
|
||||
try { await tauri.core.invoke("vault_init", { password: pw.value }); }
|
||||
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(btn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Create vault");
|
||||
view.replaceChildren(card(
|
||||
"Create your vault",
|
||||
"Local, encrypted, Argon2id + XChaCha20-Poly1305. The password is not recoverable — lose it and the contents are gone.",
|
||||
pw, pw2, btn, err
|
||||
));
|
||||
}
|
||||
|
||||
function renderLocked() {
|
||||
setPill("locked");
|
||||
const pw = el("input", { type: "password", placeholder: "Vault password", autocomplete: "current-password", autofocus: true });
|
||||
const ttl = el("select", {},
|
||||
el("option", { value: "900" }, "Unlock for 15 minutes"),
|
||||
el("option", { value: "3600" }, "Unlock for 1 hour"),
|
||||
el("option", { value: "28800" }, "Unlock for 8 hours"),
|
||||
el("option", { value: "86400" }, "Unlock for 24 hours"));
|
||||
const err = el("p", { class: "err", hidden: true });
|
||||
const btn = el("button", {
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
busy(btn, "Unlocking…");
|
||||
try { await tauri.core.invoke("vault_unlock", { password: pw.value, ttl: Number(ttl.value) }); }
|
||||
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(btn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Unlock");
|
||||
pw.addEventListener("keydown", (e) => { if (e.key === "Enter") btn.click(); });
|
||||
view.replaceChildren(card(
|
||||
"Vault is locked",
|
||||
"Unlocks a timed session — agents and scripts can use secrets without the password until it expires.",
|
||||
pw, ttl, btn, err
|
||||
));
|
||||
}
|
||||
|
||||
function secretRow(s) {
|
||||
const copy = el("button", { class: "ghost sm", title: "Copy value" }, "copy");
|
||||
copy.addEventListener("click", async () => {
|
||||
try {
|
||||
const value = await tauri.core.invoke("vault_reveal", { name: s.name });
|
||||
await navigator.clipboard.writeText(value.trimEnd());
|
||||
copy.textContent = "copied";
|
||||
setTimeout(() => (copy.textContent = "copy"), 1200);
|
||||
} catch (e) { copy.textContent = "error"; setTimeout(() => (copy.textContent = "copy"), 1500); }
|
||||
});
|
||||
const del = el("button", { class: "ghost sm danger", title: "Delete" }, "delete");
|
||||
del.addEventListener("click", async () => {
|
||||
if (del.dataset.armed) {
|
||||
try { await tauri.core.invoke("vault_rm", { name: s.name }); } catch {}
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
del.dataset.armed = "1";
|
||||
del.textContent = "sure?";
|
||||
setTimeout(() => { delete del.dataset.armed; del.textContent = "delete"; }, 2500);
|
||||
});
|
||||
return el("tr", {},
|
||||
el("td", { class: "mono" }, s.name),
|
||||
el("td", {}, s.url ? el("span", {
|
||||
class: "vlink",
|
||||
title: `${s.url} — click to copy`,
|
||||
onclick: async () => { await navigator.clipboard.writeText(s.url).catch(() => {}); },
|
||||
}, s.url.replace(/^https?:\/\//, "").slice(0, 34)) : "—"),
|
||||
el("td", { class: "dimcell", title: s.notes ?? "" }, (s.notes ?? "—").slice(0, 34)),
|
||||
el("td", { class: "dimcell" }, (s.updated_at ?? "").slice(0, 10)),
|
||||
el("td", { class: "acts" }, copy, del));
|
||||
}
|
||||
|
||||
async function renderUnlocked(st) {
|
||||
setPill("unlocked", st.expires_at);
|
||||
const secrets = await tauri.core.invoke("vault_secrets").catch(() => []);
|
||||
|
||||
const table = secrets.length
|
||||
? el("table", { class: "vtable" },
|
||||
el("thead", {}, el("tr", {},
|
||||
el("th", {}, "name"), el("th", {}, "url"), el("th", {}, "notes"),
|
||||
el("th", {}, "updated"), el("th", {}, ""))),
|
||||
el("tbody", {}, ...secrets.map(secretRow)))
|
||||
: dimNote("Empty — add a secret below or import a .env file.");
|
||||
|
||||
// inline add form
|
||||
const name = el("input", { placeholder: "NAME", class: "mono", spellcheck: "false" });
|
||||
const value = el("input", { type: "password", placeholder: "value", autocomplete: "off" });
|
||||
const url = el("input", { placeholder: "url (optional)", inputmode: "url" });
|
||||
const notes = el("input", { placeholder: "notes (optional)" });
|
||||
const err = el("p", { class: "err", hidden: true });
|
||||
const addBtn = el("button", {
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name.value)) {
|
||||
err.textContent = "Name must be env-safe: [A-Za-z_][A-Za-z0-9_]*";
|
||||
err.hidden = false; return;
|
||||
}
|
||||
if (!value.value) { err.textContent = "Value required."; err.hidden = false; return; }
|
||||
busy(addBtn, "Encrypting…");
|
||||
try {
|
||||
await tauri.core.invoke("vault_set", {
|
||||
name: name.value, value: value.value, url: url.value || null, notes: notes.value || null,
|
||||
});
|
||||
} catch (e) { err.textContent = String(e); err.hidden = false; unbusy(addBtn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Add secret");
|
||||
|
||||
const importPath = el("input", { placeholder: "/path/to/.env", class: "mono grow" });
|
||||
const importBtn = el("button", {
|
||||
class: "ghost",
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
if (!importPath.value.trim()) return;
|
||||
busy(importBtn, "Importing…");
|
||||
try { await tauri.core.invoke("vault_import", { path: importPath.value.trim(), overwrite: false }); }
|
||||
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(importBtn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Import .env");
|
||||
|
||||
view.replaceChildren(
|
||||
el("section", { class: "vcard" },
|
||||
el("h2", {}, `${secrets.length} secret${secrets.length === 1 ? "" : "s"}`),
|
||||
table,
|
||||
el("div", { class: "vform" }, name, value, url, notes, addBtn),
|
||||
el("div", { class: "vform" }, importPath, importBtn),
|
||||
err),
|
||||
el("p", { class: "hint" },
|
||||
"Values are never shown here — copy goes straight to the clipboard. " +
|
||||
"Injection and the credential broker stay in the CLI: primora inject / primora agent.")
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
|
||||
async function refresh() {
|
||||
if (!tauri) {
|
||||
view.replaceChildren(errBox("This page is meant to run inside the Primora desktop app."));
|
||||
return;
|
||||
}
|
||||
const ok = await tauri.core.invoke("vault_probe").catch(() => false);
|
||||
if (!ok) return renderMissing();
|
||||
const st = await tauri.core.invoke("vault_status").catch((e) => ({ error: String(e) }));
|
||||
if (st.error) { view.replaceChildren(errBox(st.error)); return; }
|
||||
pathEl.textContent = st.path ?? "";
|
||||
if (!st.exists) return renderInit();
|
||||
if (!st.unlocked) return renderLocked();
|
||||
renderUnlocked(st);
|
||||
}
|
||||
|
||||
lockBtn.addEventListener("click", async () => {
|
||||
await tauri.core.invoke("vault_lock").catch(() => {});
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Keep the pill/table honest if the session expires while the window sits open.
|
||||
setInterval(async () => {
|
||||
const st = await tauri?.core.invoke("vault_status").catch(() => null);
|
||||
if (st && !st.unlocked && !lockBtn.hidden) refresh();
|
||||
}, 30000);
|
||||
|
||||
refresh();
|
||||
@@ -27,13 +27,20 @@ RUN npm run build
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=builder /app/apps/frontend/dist /usr/share/nginx/html
|
||||
# Add simple nginx config for SPA routing
|
||||
# Add simple nginx config for SPA routing. index.html must always revalidate —
|
||||
# it references hashed assets that change every deploy. Hashed /assets files are
|
||||
# immutable and safe to cache long-term.
|
||||
RUN echo 'server { \
|
||||
listen 80; \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
location /assets/ { \
|
||||
try_files $uri =404; \
|
||||
add_header Cache-Control "public, max-age=31536000, immutable"; \
|
||||
} \
|
||||
location / { \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
add_header Cache-Control "no-cache, must-revalidate"; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
|
||||
@@ -16,13 +16,6 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Primora" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<style>
|
||||
html { background: #0a0a0b; color-scheme: dark; }
|
||||
html.light { background: #fafaf9; color-scheme: light; }
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.3.0",
|
||||
"@fontsource/geist-mono": "^5.3.0",
|
||||
"@primora/api-client": "0.5.0",
|
||||
"@primora/shared-types": "0.5.0",
|
||||
"better-auth": "^1.5.6",
|
||||
|
||||
@@ -26,9 +26,10 @@ import {
|
||||
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js";
|
||||
|
||||
import { authClient, fetchApiToken } from "./lib/auth-client";
|
||||
import { configureApiToken } from "./lib/api";
|
||||
import { configureApiToken, errorMessage } from "./lib/api";
|
||||
import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode";
|
||||
import {
|
||||
AgentSetupModal,
|
||||
AppShell,
|
||||
CommandPaletteEnhanced,
|
||||
NetworkError,
|
||||
@@ -47,6 +48,8 @@ import {
|
||||
DatabasesPage,
|
||||
TelemetryPage,
|
||||
IntegrationsPage,
|
||||
VaultPage,
|
||||
FunctionsPage,
|
||||
LoginPage,
|
||||
MembersPage,
|
||||
ProjectsPage,
|
||||
@@ -62,6 +65,7 @@ import {
|
||||
IconDatabases,
|
||||
IconTelemetry,
|
||||
IconZap,
|
||||
IconTerminal,
|
||||
IconCollections,
|
||||
IconAudit,
|
||||
IconAuth,
|
||||
@@ -81,11 +85,7 @@ type ObjectPreview =
|
||||
| { kind: "unsupported"; message: string };
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
const message = (error as { message?: unknown }).message;
|
||||
if (typeof message === "string" && message.length > 0) return message;
|
||||
}
|
||||
return fallback;
|
||||
return errorMessage(error, fallback);
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
@@ -128,6 +128,7 @@ export default function App() {
|
||||
const [name, setName] = createSignal("");
|
||||
const [activeView, setActiveView] = createSignal<ViewType>("dashboard");
|
||||
const [showOnboarding, setShowOnboarding] = createSignal(false);
|
||||
const [agentSetupOpen, setAgentSetupOpen] = createSignal(false);
|
||||
const [paletteOpen, setPaletteOpen] = createSignal(false);
|
||||
const [theme, setTheme] = createSignal<"dark" | "light">(
|
||||
(localStorage.getItem("primora_theme") as "dark" | "light") ?? "dark",
|
||||
@@ -1531,6 +1532,8 @@ export default function App() {
|
||||
{ id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: <IconDatabases class="w-4 h-4" />, keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") },
|
||||
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
|
||||
{ id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: <IconZap class="w-4 h-4" />, keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") },
|
||||
{ id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: <IconKey class="w-4 h-4" />, keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") },
|
||||
{ id: "nav-functions", label: "Go to Functions", category: "Navigate", icon: <IconTerminal class="w-4 h-4" />, keywords: ["serverless", "code", "lambda", "invoke", "edge"], action: () => setActiveView("functions") },
|
||||
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
|
||||
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
|
||||
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
|
||||
@@ -1695,6 +1698,7 @@ export default function App() {
|
||||
overview={projectOverview()}
|
||||
recentAudit={auditLogs()}
|
||||
onNavigate={(view) => setActiveView(view as ViewType)}
|
||||
onAgentSetup={() => setAgentSetupOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -1715,11 +1719,14 @@ export default function App() {
|
||||
onDeleteProject={deleteActiveProject}
|
||||
onSelectProject={setSelectedProjectID}
|
||||
onNavigateToDashboard={() => setActiveView("dashboard")}
|
||||
onAgentSetup={() => setAgentSetupOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "members"}>
|
||||
<MembersPage
|
||||
projectID={activeProject()?.id}
|
||||
demoMode={isDemo}
|
||||
organizationMembers={organizationMembers()}
|
||||
organizationInvitations={organizationInvitations()}
|
||||
projectMembers={projectMembers()}
|
||||
@@ -1772,6 +1779,22 @@ export default function App() {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "vault"}>
|
||||
<VaultPage
|
||||
projectID={activeProject()?.id}
|
||||
canManage={canUpdateProject()}
|
||||
demoMode={isDemo}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "functions"}>
|
||||
<FunctionsPage
|
||||
projectID={activeProject()?.id}
|
||||
canManage={canUpdateProject()}
|
||||
demoMode={isDemo}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "storage"}>
|
||||
<StoragePage
|
||||
buckets={buckets()}
|
||||
@@ -1947,6 +1970,23 @@ export default function App() {
|
||||
isOpen={showOnboarding()}
|
||||
projectName={activeProject()?.name ?? "Your project"}
|
||||
onClose={() => setShowOnboarding(false)}
|
||||
onDone={() => {
|
||||
setShowOnboarding(false);
|
||||
setAgentSetupOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AgentSetupModal
|
||||
open={agentSetupOpen()}
|
||||
onClose={() => setAgentSetupOpen(false)}
|
||||
project={activeProject()}
|
||||
canManage={canUpdateProject() || isDemo}
|
||||
demoMode={isDemo}
|
||||
onKeyCreated={() => {
|
||||
void refetchAPIKeys();
|
||||
void refetchAuditLogs();
|
||||
void refreshProjectOverviewSnapshot();
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
);
|
||||
|
||||
Loaded 100 of 209 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user