mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
Compare commits
48
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 | ||
|
|
112cf84362 | ||
|
|
61ede5ee99 | ||
|
|
2fbde04563 | ||
|
|
9c118d41c2 | ||
|
|
c0c5e1cfcb | ||
|
|
28b10eee2b | ||
|
|
23868ef7c9 | ||
|
|
10c667dffd | ||
|
|
11aa82b459 | ||
|
|
9658e167ba | ||
|
|
e71db32885 | ||
|
|
c6166de886 | ||
|
|
1214108d6b | ||
|
|
4f66c72121 | ||
|
|
d3dc84028d | ||
|
|
591dedab2f |
No files matched your search
@@ -29,6 +29,14 @@ BETTER_AUTH_SECRET=change-me-super-long-better-auth-secret
|
||||
BETTER_AUTH_URL=http://localhost/auth
|
||||
# Comma-separated emails promoted to auth admin on boot (Authentication → Users panel)
|
||||
AUTH_ADMIN_EMAILS=
|
||||
# The first public sign-up always becomes the instance admin. With no users the
|
||||
# login page shows only the sign-up form. After bootstrap, public sign-up is
|
||||
# closed unless SIGNUP_ENABLED=true or the admin enables it in-app
|
||||
# (Settings → Instance). In-app values override this file.
|
||||
SIGNUP_ENABLED=false
|
||||
# Managed tier flag — OAuth providers activate only when this is "true".
|
||||
# Self-hosted instances are email/password regardless of the keys below.
|
||||
PRIMORA_MANAGED=false
|
||||
COOKIE_DOMAIN=localhost
|
||||
|
||||
FRONTEND_PORT=3000
|
||||
@@ -42,6 +50,33 @@ 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=
|
||||
MAIL_FROM=Primora <[email protected]>
|
||||
SMTP_HOST=mailpit
|
||||
@@ -50,6 +85,7 @@ SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_SECURE=false
|
||||
|
||||
# OAuth keys only take effect when PRIMORA_MANAGED=true (managed tier).
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
|
||||
+188
-16
@@ -1,25 +1,54 @@
|
||||
name: Release
|
||||
|
||||
# Triggered by semver tags: git tag v0.3.0 && git push --tags
|
||||
# Tag must match the root package.json version.
|
||||
# Triggered two ways:
|
||||
# 1. Semver tags: git tag v0.3.0 && git push --tags (must match package.json).
|
||||
# 2. workflow_run: after the CI workflow succeeds on master, the version in
|
||||
# root package.json is released automatically if no matching tag exists.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*.*.*"]
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Verify tag
|
||||
name: Verify version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
needed: ${{ steps.check.outputs.needed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Resolve version + decide whether to release
|
||||
id: check
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_run" ]; then
|
||||
if [ "${{ github.event.workflow_run.conclusion }}" != "success" ]; then
|
||||
echo "CI did not succeed — skipping release."
|
||||
echo "needed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
VERSION="v$(node -p "require('./package.json').version")"
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" > /dev/null 2>&1; then
|
||||
echo "$VERSION already released — nothing to do."
|
||||
echo "needed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
VERSION="${{ github.ref_name }}"
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "needed=true" >> "$GITHUB_OUTPUT"
|
||||
- name: Check tag matches package versions
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.check.outputs.version }}
|
||||
run: |
|
||||
PKG=$(node -p "require('./package.json').version")
|
||||
DESKTOP_PKG=$(node -p "require('./apps/desktop/package.json').version")
|
||||
@@ -30,16 +59,20 @@ jobs:
|
||||
fi
|
||||
|
||||
images:
|
||||
name: Build + push ${{ matrix.app }}
|
||||
name: Build + push ${{ matrix.app }} (${{ matrix.platform }})
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.verify.outputs.needed == 'true'
|
||||
# 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
|
||||
@@ -54,17 +87,43 @@ 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 }}:${{ env.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 }}
|
||||
needs: verify
|
||||
if: needs.verify.outputs.needed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -94,6 +153,7 @@ jobs:
|
||||
desktop:
|
||||
name: Desktop bundle (${{ matrix.name }})
|
||||
needs: verify
|
||||
if: needs.verify.outputs.needed == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -114,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:
|
||||
@@ -123,13 +188,17 @@ 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:
|
||||
name: Pack npm tarballs
|
||||
needs: verify
|
||||
if: needs.verify.outputs.needed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -151,8 +220,11 @@ jobs:
|
||||
|
||||
release:
|
||||
name: GitHub Release
|
||||
needs: [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:
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# build-push-action uploads "*.dockerbuild" metadata artifacts that
|
||||
@@ -171,6 +243,16 @@ jobs:
|
||||
with:
|
||||
name: npm-tarballs
|
||||
path: dist-release
|
||||
# workflow_run runs leave the release untagged — create it so the
|
||||
# GitHub Release points at a real tag. GITHUB_TOKEN pushes do not
|
||||
# re-trigger workflows, so this cannot recurse.
|
||||
- name: Tag release
|
||||
if: github.event_name == 'workflow_run'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$VERSION"
|
||||
git push origin "$VERSION"
|
||||
- name: Extract changelog section
|
||||
run: |
|
||||
awk -v ver="${VERSION#v}" '
|
||||
@@ -179,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 }}
|
||||
@@ -188,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,91 @@
|
||||
|
||||
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
|
||||
|
||||
- **Self-hosted sign-up no longer requires mail infrastructure** — `sendTransactionalEmail` warned-and-returned when no transport was configured instead of throwing, so SMTP-less instances 500'd on sign-up. `sendVerificationEmail` now also catches transport failures; a broken SMTP cannot block account creation (verification stays optional).
|
||||
- **Password managers detect the login form** — the email/password fields gained `id`/`name` attributes and `label for`/`id` pairing, and the email input uses `autocomplete="username"` (Proton Pass et al.).
|
||||
- **Installer prebuilt path strips build blocks** — `install.sh` previously kept `*-build` compose blocks when pulling prebuilt GHCR images.
|
||||
|
||||
### Added
|
||||
|
||||
- **Static landing page** (`landing/`) — self-contained marketing site with bundled Geist fonts and product screenshots; deployed separately from the app artifacts.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- **Automatic releases on merge** — merges to `master` whose root `package.json` version has no matching `v*` tag now trigger the full release pipeline (GHCR images, CLI binaries, desktop bundles, npm tarballs, GitHub Release). Tag pushes still work as before.
|
||||
|
||||
## [0.7.0] - 2026-09-21
|
||||
|
||||
### Changed
|
||||
|
||||
- **Public sign-up closes by default after the first account.** On upgrade, existing instances keep their users but new public sign-ups are rejected until an admin enables them (Settings → Instance or `SIGNUP_ENABLED=true`). The oldest existing account is not automatically promoted — set `AUTH_ADMIN_EMAILS` or update `user.role` to `admin` manually if no platform admin exists.
|
||||
- **OAuth providers require `PRIMORA_MANAGED=true`.** Self-hosted deployments are email/password only; GitHub/Google/Discord/Microsoft env keys are ignored without the flag.
|
||||
|
||||
### Added
|
||||
|
||||
- **Instance administration** — `core.settings` (migration 00010) stores in-app overrides that resolve ahead of `.env` and built-in defaults. New endpoints: `GET /instance/public` (unauthenticated login-page state), `GET/PUT/DELETE /instance/settings[/key]` (platform admin only — better-auth `user.role`, not org role). Manageable keys cover public sign-up, mail transport (Resend or SMTP), and API/user rate limits. Secrets are AES-256-GCM encrypted at rest, never returned by the API, and decryptable by the auth service via the shared `PRIMORA_ENCRYPTION_KEY`.
|
||||
- **Bootstrap + sign-up gating** — the first public sign-up becomes the instance admin; afterwards public sign-up is closed unless `SIGNUP_ENABLED=true` or the admin reopens it in Settings → Instance. `databaseHooks.user.create.before` gates only `/sign-up/email`, so admin-created users and OAuth flows bypass it.
|
||||
- **Managed-tier OAuth gate** — `PRIMORA_MANAGED=true` is now required for GitHub/Google/Discord/Microsoft sign-in; self-hosted instances are email/password only. The login page hides the sign-up tab and social buttons according to `GET /instance/public`.
|
||||
- **Settings → Instance tab** — platform admins edit sign-up, mail, and rate-limit settings in-app with source badges (in-app/env/default) and per-key reset.
|
||||
|
||||
## [0.6.0] - 2026-09-21
|
||||
|
||||
### Added
|
||||
|
||||
+35
-3
@@ -9,9 +9,24 @@ cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
For native development run only the infrastructure in Docker
|
||||
(`docker compose up -d postgres dragonfly mailpit`) and start the three
|
||||
services yourself.
|
||||
For native development run only the infrastructure in Docker and start the
|
||||
three services yourself:
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres dragonfly mailpit # infrastructure only
|
||||
cd apps/backend && go run ./cmd/server # API on :8080
|
||||
npm run dev:auth # auth on :3001
|
||||
npm run dev:frontend # dashboard (Vite)
|
||||
```
|
||||
|
||||
Point the frontend at the stack via `apps/frontend/.env.local`:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost/api/v1
|
||||
VITE_AUTH_BASE_URL=http://localhost/auth
|
||||
```
|
||||
|
||||
Or skip the backend entirely: `?demo=true` runs a fully client-side workspace.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -29,6 +44,23 @@ services yourself.
|
||||
If you changed `apps/backend/openapi/openapi.yaml`, run
|
||||
`npm run generate:client`. Commit the regenerated output with your change.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
apps/
|
||||
backend/ Go + Gin API, sqlc, migrations, object storage
|
||||
auth/ Better Auth on Hono (sessions, JWT, OAuth, mail)
|
||||
frontend/ SolidJS + Tailwind dashboard
|
||||
cli/ `primora` CLI - login, context, projects, buckets, objects, keys, audit
|
||||
mcp/ `@primora/mcp` - MCP stdio server exposing `primora_*` tools
|
||||
desktop/ Tauri 2 shell - connects to a deployment URL, tray presence
|
||||
packages/
|
||||
api-client/ OpenAPI-generated TypeScript client (do not hand-edit)
|
||||
shared-types/
|
||||
infra/nginx/ Reverse proxy config
|
||||
scripts/ setup.sh, verify-production-ready.sh
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Generated code is never hand-edited** — `packages/api-client/src/generated`
|
||||
|
||||
+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
|
||||
|
||||
|
||||
+29
-2
@@ -61,10 +61,37 @@ 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`) |
|
||||
| `GITHUB_CLIENT_ID` / `GOOGLE_CLIENT_ID` / `DISCORD_*` / `MICROSOFT_*` | Optional OAuth providers |
|
||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling, `0` disables |
|
||||
| `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
|
||||
|
||||
The **first public sign-up becomes the instance admin** — on a fresh database the
|
||||
login page only offers sign-up. After that, public sign-up is closed and the
|
||||
admin can reopen it in-app.
|
||||
|
||||
Admins get a **Settings → Instance** tab that manages the instance without a
|
||||
restart: public sign-up, mail transport (Resend or SMTP), and rate limits.
|
||||
Every setting resolves in the order **in-app value → `.env` → built-in
|
||||
default**; resetting a key reverts to env/default. Secrets (SMTP password,
|
||||
Resend key) are stored AES-256-GCM-encrypted and never displayed back.
|
||||
|
||||
API equivalents:
|
||||
|
||||
```bash
|
||||
GET /api/v1/instance/public # unauthenticated — login page state
|
||||
GET /api/v1/instance/settings # platform admin
|
||||
PUT /api/v1/instance/settings/{key} # platform admin — {"value": ...}
|
||||
DELETE /api/v1/instance/settings/{key} # platform admin — revert to env/default
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,6 +1,65 @@
|
||||
# Primora
|
||||
<p align="center">
|
||||
<img src="./primora.svg" alt="Primora" width="120">
|
||||
</p>
|
||||
|
||||
A self-hosted backend platform for building products — auth, Postgres-backed APIs, object storage, JSON document collections, API keys, and a full audit log behind one dashboard. Inspired by the UX of Appwrite and Supabase, with no hosted tier: your data stays on your hardware.
|
||||
<h1 align="center">Primora</h1>
|
||||
|
||||
<p align="center">
|
||||
Self-hosted backend platform for building products.<br>
|
||||
Auth, Postgres-backed APIs, object storage, and audit logs on your own hardware.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#quick-start">Quick Start</a> ·
|
||||
<a href="QUICK_START.md">Documentation</a> ·
|
||||
<a href="https://github.com/Dvorinka/Primora/releases">Releases</a> ·
|
||||
<a href="CONTRIBUTING.md">Contributing</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Dvorinka/Primora/actions/workflows/ci.yml"><img src="https://github.com/Dvorinka/Primora/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://github.com/Dvorinka/Primora/releases"><img src="https://img.shields.io/github/v/release/Dvorinka/Primora" alt="Release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Dvorinka/Primora" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## What is Primora?
|
||||
|
||||
Primora is a self-hosted backend platform: organizations, projects, auth,
|
||||
S3-style object storage, JSON document collections, scoped API keys, and a
|
||||
full audit log behind one dashboard. Inspired by the UX of Appwrite and
|
||||
Supabase, with no hosted tier - your data stays on your hardware.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Overview | Storage | Telemetry |
|
||||
|:-:|:-:|:-:|
|
||||
|  |  |  |
|
||||
|
||||
<details>
|
||||
<summary>More screenshots</summary>
|
||||
|
||||
| Collections | Automation | Audit log |
|
||||
|:-:|:-:|:-:|
|
||||
|  |  |  |
|
||||
|
||||
</details>
|
||||
|
||||
## Features
|
||||
|
||||
- **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 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`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite)
|
||||
@@ -10,153 +69,51 @@ Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite)
|
||||
──▶ Local filesystem object storage
|
||||
```
|
||||
|
||||
## What you get
|
||||
|
||||
- **Organizations & projects** — top-level workspaces containing projects, members, and scoped roles (`owner`/`admin`/`member` org roles, `admin`/`developer`/`viewer` project roles).
|
||||
- **Auth** — email/password plus optional GitHub, Google, Discord, and Microsoft OAuth via Better Auth. JWTs minted by the auth service are verified by the Go API against JWKS.
|
||||
- **Storage** — S3-style buckets and objects backed by your local filesystem, with public/private visibility and downloadable URLs.
|
||||
- **Collections** — schema-flexible JSON documents stored in Postgres JSONB.
|
||||
- **API keys** — `prm_<prefix>_<secret>` credentials; secrets are shown once.
|
||||
- **Audit log** — every mutating request recorded with actor, resource, request ID, and timestamp; CSV/JSON export from the dashboard.
|
||||
- **Generated client** — the TypeScript client is generated from `apps/backend/openapi/openapi.yaml`, so the API contract is the source of truth.
|
||||
- **Demo mode** — a fully client-side workspace (`?demo=true` or `VITE_DEMO_MODE=true`) for trying the UI without a backend.
|
||||
|
||||
## Install
|
||||
## Quick Start
|
||||
|
||||
Prerequisites: Docker with the Compose plugin.
|
||||
|
||||
### One-liner (no clone)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash
|
||||
```
|
||||
|
||||
Installs into `./primora`, generates secrets, and starts the stack on port 80.
|
||||
Non-interactive — configure with env vars:
|
||||
|
||||
```bash
|
||||
DOMAIN=example.com NGINX_PORT=8080 PRIMORA_DIR=/opt/primora bash install.sh
|
||||
```
|
||||
|
||||
The installer pulls prebuilt images from GHCR when they are published;
|
||||
otherwise it downloads the source tarball and builds locally.
|
||||
|
||||
### From a clone
|
||||
Or from a clone:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Dvorinka/Primora.git && cd Primora
|
||||
./scripts/setup.sh # creates .env, generates secrets, starts the stack
|
||||
```
|
||||
|
||||
or manually:
|
||||
Then open `http://localhost` - or append `?demo=true` to explore a seeded
|
||||
workspace without signing up. For prebuilt images, native development, port
|
||||
conflicts, and troubleshooting see [QUICK_START.md](QUICK_START.md).
|
||||
|
||||
```bash
|
||||
cp .env.example .env # fill JWT_SECRET and BETTER_AUTH_SECRET
|
||||
docker compose up -d # builds the three app images from source
|
||||
```
|
||||
## Configuration
|
||||
|
||||
To run published images instead of building:
|
||||
All configuration lives in `.env` - see `.env.example` for the annotated list
|
||||
and [QUICK_START.md](QUICK_START.md#configuration) for the important ones.
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/dvorinka/primora-{backend,auth,frontend}:latest
|
||||
docker compose up -d --no-build
|
||||
```
|
||||
## Ecosystem
|
||||
|
||||
Then open `http://localhost`. For local email capture, add the dev overlay
|
||||
(`-f docker-compose.dev.yml`) and Mailpit is at `http://localhost/mailpit/`.
|
||||
|
||||
If ports `5432` or `6379` are already in use on your machine, set `POSTGRES_PORT` and `DRAGONFLY_PORT` in `.env` — only the host bindings change.
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
docker compose ps # service status + health
|
||||
docker compose logs -f backend # follow one service
|
||||
docker compose pull && docker compose up -d # update to latest release
|
||||
docker compose down # stop (data persists in volumes)
|
||||
docker compose down -v # stop and delete all data
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Endpoint |
|
||||
|---|---|
|
||||
| Frontend | `http://localhost/` |
|
||||
| Backend liveness | `http://localhost/api/v1/health/liveness` |
|
||||
| Backend readiness | `http://localhost/api/v1/health/readiness` |
|
||||
| Auth health | `http://localhost/auth-health` |
|
||||
| OpenAPI spec | `http://localhost/api/v1/openapi.yaml` |
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres dragonfly mailpit # infrastructure only
|
||||
cd apps/backend && go run ./cmd/server # API on :8080
|
||||
npm run dev:auth # auth on :3001
|
||||
npm run dev:frontend # dashboard (Vite)
|
||||
```
|
||||
|
||||
Point the frontend at the stack by adding to `apps/frontend/.env.local`:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost/api/v1
|
||||
VITE_AUTH_BASE_URL=http://localhost/auth
|
||||
```
|
||||
|
||||
Or skip all of it: `http://localhost:<vite-port>/?demo=true` runs entirely in the browser.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
npm run build --workspace @primora/cli # → apps/cli/dist/cli.js
|
||||
node apps/cli/dist/cli.js login # or: npx primora login once published
|
||||
```
|
||||
|
||||
Supports session sign-in (`primora login`) and API-key mode (`primora login --api-key prm_…` or `PRIMORA_API_KEY`). Context lives in `~/.config/primora/config.json`; `primora use` picks org + project interactively. Commands: `orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`, `audit list --follow`. Every command accepts `--json`.
|
||||
|
||||
## Desktop & phone
|
||||
|
||||
- **Desktop** — `apps/desktop` is a Tauri 2 shell (Linux/Windows/macOS). First
|
||||
run asks for your deployment URL, then renders the same dashboard in a
|
||||
webview with a system-tray presence. Nothing is bundled; your server does the
|
||||
work. See [apps/desktop/README.md](apps/desktop/README.md).
|
||||
- **Phone / PWA** — the dashboard ships a web manifest and service worker.
|
||||
Open your deployment in a mobile browser and "Add to Home Screen"; the shell
|
||||
is precached, API traffic always goes to the network. Chromium desktop
|
||||
browsers get an in-app install banner.
|
||||
|
||||
## Quality gate
|
||||
|
||||
```bash
|
||||
npm run check # backend tests + frontend typecheck + build + generated-code drift check
|
||||
npm test # workspace tests
|
||||
npm run generate:sqlc # regenerate sqlc after changing queries/schema
|
||||
npm run generate:client # regenerate the TS client after changing openapi.yaml
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
apps/
|
||||
backend/ Go + Gin API, sqlc, migrations, object storage
|
||||
auth/ Better Auth on Hono (sessions, JWT, OAuth, mail)
|
||||
frontend/ SolidJS + Tailwind dashboard
|
||||
cli/ `primora` CLI — login, context, projects, buckets, objects, keys, audit
|
||||
mcp/ `@primora/mcp` — MCP stdio server exposing `primora_*` tools
|
||||
desktop/ Tauri 2 shell — connects to a deployment URL, tray presence
|
||||
packages/
|
||||
api-client/ OpenAPI-generated TypeScript client (do not hand-edit)
|
||||
shared-types/
|
||||
infra/nginx/ Reverse proxy config
|
||||
scripts/ setup.sh, verify-production-ready.sh
|
||||
```
|
||||
- **[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 and a local Secrets Vault window (drives the CLI's vault).
|
||||
- **PWA** - the dashboard is installable on mobile and desktop browsers.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [QUICK_START.md](QUICK_START.md) — setup, configuration, troubleshooting
|
||||
- [project_backend.md](project_backend.md) / [project_frontend.md](project_frontend.md) — original design specs
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — development workflow
|
||||
- `apps/backend/openapi/openapi.yaml` — API contract
|
||||
- [QUICK_START.md](QUICK_START.md) - setup, configuration, troubleshooting
|
||||
- [project_backend.md](project_backend.md) / [project_frontend.md](project_frontend.md) - original design specs
|
||||
- `apps/backend/openapi/openapi.yaml` - API contract
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow.
|
||||
|
||||
## Security
|
||||
|
||||
See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import { requestId } from "hono/request-id";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { createClient } from "redis";
|
||||
|
||||
import { auth, authPool, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js";
|
||||
import { auth, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js";
|
||||
import { authPool } from "./lib/db.js";
|
||||
import { env } from "./lib/env.js";
|
||||
|
||||
const redisClient = createClient({
|
||||
|
||||
+79
-46
@@ -1,50 +1,53 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { admin, jwt } from "better-auth/plugins";
|
||||
import { getMigrations } from "better-auth/db/migration";
|
||||
import { Pool } from "pg";
|
||||
|
||||
import { authPool } from "./db.js";
|
||||
import { sendTransactionalEmail } from "./mail.js";
|
||||
import { getBoolSetting } from "./settings.js";
|
||||
import { env } from "./env.js";
|
||||
|
||||
export const authPool = new Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
});
|
||||
|
||||
const socialProviders = {
|
||||
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
||||
? {
|
||||
github: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
||||
? {
|
||||
google: {
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
|
||||
? {
|
||||
discord: {
|
||||
clientId: env.DISCORD_CLIENT_ID,
|
||||
clientSecret: env.DISCORD_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
|
||||
? {
|
||||
microsoft: {
|
||||
clientId: env.MICROSOFT_CLIENT_ID,
|
||||
clientSecret: env.MICROSOFT_CLIENT_SECRET,
|
||||
tenantId: env.MICROSOFT_TENANT_ID,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
// OAuth providers exist only on the managed tier (PRIMORA_MANAGED=true).
|
||||
// Self-hosted instances are email/password only — the env keys are ignored
|
||||
// unless the managed flag is set.
|
||||
const socialProviders = env.PRIMORA_MANAGED
|
||||
? {
|
||||
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
||||
? {
|
||||
github: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
||||
? {
|
||||
google: {
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
|
||||
? {
|
||||
discord: {
|
||||
clientId: env.DISCORD_CLIENT_ID,
|
||||
clientSecret: env.DISCORD_CLIENT_SECRET,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
|
||||
? {
|
||||
microsoft: {
|
||||
clientId: env.MICROSOFT_CLIENT_ID,
|
||||
clientSecret: env.MICROSOFT_CLIENT_SECRET,
|
||||
tenantId: env.MICROSOFT_TENANT_ID,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {};
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "Primora",
|
||||
@@ -63,16 +66,46 @@ export const auth = betterAuth({
|
||||
});
|
||||
},
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
// Gates only the public sign-up endpoint. Users created through the
|
||||
// admin plugin or OAuth callbacks take different paths and pass.
|
||||
async before(user, ctx) {
|
||||
if (ctx?.path !== "/sign-up/email") return;
|
||||
const { rows } = await authPool.query<{ n: number }>(
|
||||
`select count(*)::int as n from "user"`,
|
||||
);
|
||||
if (rows[0].n === 0) {
|
||||
// Bootstrap: the first public signup becomes the instance admin.
|
||||
return { data: { ...user, role: "admin" } };
|
||||
}
|
||||
const enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED);
|
||||
if (!enabled) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "Sign-up is disabled on this instance.",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
async sendVerificationEmail({ user, url }) {
|
||||
await sendTransactionalEmail({
|
||||
to: user.email,
|
||||
subject: "Verify your Primora email",
|
||||
text: `Verify your Primora email: ${url}`,
|
||||
html: `<p>Verify your Primora email.</p><p><a href="${url}">Verify email</a></p>`,
|
||||
});
|
||||
// Verification is optional — a misconfigured transport must not fail
|
||||
// sign-up or sign-in.
|
||||
try {
|
||||
await sendTransactionalEmail({
|
||||
to: user.email,
|
||||
subject: "Verify your Primora email",
|
||||
text: `Verify your Primora email: ${url}`,
|
||||
html: `<p>Verify your Primora email.</p><p><a href="${url}">Verify email</a></p>`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(JSON.stringify({ level: "warn", msg: "verification_email_failed", error }));
|
||||
}
|
||||
},
|
||||
},
|
||||
// Enabled unconditionally — better-auth only turns this on when NODE_ENV is
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
import { env } from "./env.js";
|
||||
|
||||
// Shared by better-auth and the instance-settings reader. Lives in its own
|
||||
// module so settings.ts can be imported by auth.ts without a cycle.
|
||||
export const authPool = new Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
});
|
||||
@@ -18,6 +18,15 @@ const envSchema = z.object({
|
||||
SMTP_PORT: z.string().default("1025"),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
// Managed tier flag — OAuth providers only activate when this is "true".
|
||||
// Self-hosted instances are email/password only.
|
||||
PRIMORA_MANAGED: z.string().optional(),
|
||||
// Bootstrap override for public sign-up. Without it (or an in-app setting),
|
||||
// sign-up is closed once the first user exists.
|
||||
SIGNUP_ENABLED: z.string().optional(),
|
||||
// Decrypts secret instance settings written by the backend (mail keys etc.).
|
||||
PRIMORA_ENCRYPTION_KEY: z.string().optional(),
|
||||
SMTP_SECURE: z.string().optional(),
|
||||
GITHUB_CLIENT_ID: z.string().optional(),
|
||||
GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
@@ -44,5 +53,8 @@ export const env = {
|
||||
JWT_TTL_SECONDS: Number(parsed.data.JWT_TTL_SECONDS),
|
||||
SMTP_PORT: Number(parsed.data.SMTP_PORT),
|
||||
AUTH_BASE_URL: parsed.data.AUTH_BASE_URL ?? parsed.data.BETTER_AUTH_URL,
|
||||
PRIMORA_MANAGED: parsed.data.PRIMORA_MANAGED === "true",
|
||||
SIGNUP_ENABLED: parsed.data.SIGNUP_ENABLED === "true",
|
||||
SMTP_SECURE: parsed.data.SMTP_SECURE === "true",
|
||||
};
|
||||
|
||||
+40
-23
@@ -2,23 +2,33 @@ import nodemailer from "nodemailer";
|
||||
import { Resend } from "resend";
|
||||
|
||||
import { env } from "./env.js";
|
||||
import { getStringSetting } from "./settings.js";
|
||||
|
||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null;
|
||||
|
||||
const transporter =
|
||||
!resend && env.SMTP_HOST
|
||||
? nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: env.SMTP_PORT,
|
||||
secure: false,
|
||||
auth: env.SMTP_USER
|
||||
? {
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASSWORD,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
: null;
|
||||
// Mail config resolves per send: in-app setting → env var. This keeps
|
||||
// SMTP/Resend editable from the dashboard without restarting the service.
|
||||
async function resolveMailConfig() {
|
||||
const from = (await getStringSetting("mail.from", env.MAIL_FROM))!;
|
||||
const resendApiKey = await getStringSetting("mail.resend_api_key", env.RESEND_API_KEY);
|
||||
if (resendApiKey) {
|
||||
return { from, resendApiKey } as const;
|
||||
}
|
||||
const host = await getStringSetting("mail.smtp_host", env.SMTP_HOST);
|
||||
const portRaw = await getStringSetting("mail.smtp_port", String(env.SMTP_PORT));
|
||||
const user = await getStringSetting("mail.smtp_user", env.SMTP_USER);
|
||||
const pass = await getStringSetting("mail.smtp_password", env.SMTP_PASSWORD);
|
||||
const secureRaw = await getStringSetting("mail.smtp_secure", String(env.SMTP_SECURE));
|
||||
return {
|
||||
from,
|
||||
smtp: host
|
||||
? {
|
||||
host,
|
||||
port: Number(portRaw) || 1025,
|
||||
secure: secureRaw === "true",
|
||||
auth: user ? { user, pass } : undefined,
|
||||
}
|
||||
: undefined,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function sendTransactionalEmail(input: {
|
||||
to: string;
|
||||
@@ -26,9 +36,12 @@ export async function sendTransactionalEmail(input: {
|
||||
text: string;
|
||||
html: string;
|
||||
}) {
|
||||
if (resend) {
|
||||
const cfg = await resolveMailConfig();
|
||||
|
||||
if ("resendApiKey" in cfg && cfg.resendApiKey) {
|
||||
const resend = new Resend(cfg.resendApiKey);
|
||||
await resend.emails.send({
|
||||
from: env.MAIL_FROM,
|
||||
from: cfg.from,
|
||||
to: [input.to],
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
@@ -37,16 +50,20 @@ export async function sendTransactionalEmail(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transporter) {
|
||||
throw new Error("No mail transport configured for auth service.");
|
||||
if (!("smtp" in cfg) || !cfg.smtp) {
|
||||
// Self-hosted instances may run without mail — auth still works, the
|
||||
// email simply never goes out.
|
||||
console.warn(
|
||||
JSON.stringify({ level: "warn", msg: "mail_skipped_no_transport", to: input.to, subject: input.subject }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await transporter.sendMail({
|
||||
from: env.MAIL_FROM,
|
||||
await nodemailer.createTransport(cfg.smtp).sendMail({
|
||||
from: cfg.from,
|
||||
to: input.to,
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
html: input.html,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { authPool } from "./db.js";
|
||||
import { env } from "./env.js";
|
||||
|
||||
// Instance settings live in core.settings, written by the backend. A row
|
||||
// overrides the matching env var; no row means env (or the caller's default).
|
||||
// Secret values are AES-256-GCM ciphertext (base64, nonce || ct || tag)
|
||||
// encrypted by the backend with PRIMORA_ENCRYPTION_KEY.
|
||||
|
||||
const CACHE_TTL_MS = 15_000;
|
||||
const cache = new Map<string, { value: unknown; at: number }>();
|
||||
|
||||
export async function getSetting(key: string): Promise<unknown> {
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.value;
|
||||
const { rows } = await authPool.query(
|
||||
`select value, secret from core.settings where key = $1`,
|
||||
[key],
|
||||
);
|
||||
let value: unknown;
|
||||
if (rows[0]) {
|
||||
const raw = rows[0].value as unknown; // JSONB arrives parsed — a string
|
||||
value = rows[0].secret ? decryptSetting(String(raw)) : raw;
|
||||
}
|
||||
cache.set(key, { value, at: Date.now() });
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function getBoolSetting(key: string, fallback: boolean): Promise<boolean> {
|
||||
const v = await getSetting(key);
|
||||
if (v === undefined) return fallback;
|
||||
return v === true || v === "true";
|
||||
}
|
||||
|
||||
export async function getStringSetting(key: string, fallback: string | undefined): Promise<string | undefined> {
|
||||
const v = await getSetting(key);
|
||||
if (v === undefined || v === "") return fallback;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function decryptSetting(ciphertextB64: string): string {
|
||||
const key = decodeEncryptionKey(env.PRIMORA_ENCRYPTION_KEY);
|
||||
const buf = Buffer.from(ciphertextB64, "base64");
|
||||
const nonce = buf.subarray(0, 12);
|
||||
const body = buf.subarray(12);
|
||||
const tag = body.subarray(body.length - 16);
|
||||
const ciphertext = body.subarray(0, body.length - 16);
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
// Same key formats as the Go Encryptor: 32 bytes as hex or base64.
|
||||
function decodeEncryptionKey(raw: string | undefined): Buffer {
|
||||
if (!raw) throw new Error("PRIMORA_ENCRYPTION_KEY is required to read secret settings");
|
||||
const trimmed = raw.trim();
|
||||
for (const decoded of [
|
||||
Buffer.from(trimmed, "hex"),
|
||||
Buffer.from(trimmed, "base64"),
|
||||
Buffer.from(trimmed, "base64url"),
|
||||
]) {
|
||||
if (decoded.length === 32) return decoded;
|
||||
}
|
||||
throw new Error("PRIMORA_ENCRYPTION_KEY must decode to 32 bytes (hex or base64)");
|
||||
}
|
||||
@@ -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,14 @@
|
||||
-- +goose Up
|
||||
-- Instance-level settings. In-app values override env vars; secrets are
|
||||
-- AES-256-GCM ciphertext (base64) produced by the backend's Encryptor and
|
||||
-- readable by the auth service, which shares PRIMORA_ENCRYPTION_KEY.
|
||||
CREATE TABLE core.settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
secret BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_by TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS core.settings;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- name: GetSetting :one
|
||||
SELECT key, value, secret, updated_at FROM core.settings WHERE key = $1;
|
||||
|
||||
-- name: ListSettings :many
|
||||
SELECT key, value, secret, updated_at FROM core.settings ORDER BY key;
|
||||
|
||||
-- name: UpsertSetting :one
|
||||
INSERT INTO core.settings (key, value, secret, updated_by)
|
||||
VALUES ($1, $2::jsonb, $3, $4)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value,
|
||||
secret = EXCLUDED.secret,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteSetting :exec
|
||||
DELETE FROM core.settings WHERE key = $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,14 @@ 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" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@@ -126,10 +155,12 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
||||
APIKeyPerMinute: cfg.APIKeyRateLimitPerMin,
|
||||
UserPerMinute: cfg.UserRateLimitPerMin,
|
||||
},
|
||||
RateLimitResolver: settings.RateLimitPerMinute,
|
||||
}.ResolveActor())
|
||||
|
||||
handler := &handlers.HTTPHandler{
|
||||
Platform: platform,
|
||||
Settings: settings,
|
||||
Validate: validator.New(),
|
||||
Metrics: metrics,
|
||||
Readiness: func(c *gin.Context) map[string]any {
|
||||
|
||||
@@ -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 {
|
||||
@@ -336,6 +410,14 @@ type CoreScheduledJobRun struct {
|
||||
FinishedAt pgtype.Timestamptz `json:"finished_at"`
|
||||
}
|
||||
|
||||
type CoreSetting struct {
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value"`
|
||||
Secret bool `json:"secret"`
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreUser struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AuthSubject string `json:"auth_subject"`
|
||||
|
||||
@@ -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,35 +23,44 @@ 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)
|
||||
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
|
||||
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
|
||||
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)
|
||||
@@ -60,23 +69,35 @@ 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)
|
||||
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
|
||||
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)
|
||||
@@ -86,17 +107,26 @@ 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)
|
||||
ListSettings(ctx context.Context) ([]ListSettingsRow, error)
|
||||
ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error)
|
||||
ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error)
|
||||
ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error)
|
||||
@@ -107,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
|
||||
@@ -121,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)
|
||||
@@ -132,6 +168,8 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: settings.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteSetting = `-- name: DeleteSetting :exec
|
||||
DELETE FROM core.settings WHERE key = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSetting(ctx context.Context, key string) error {
|
||||
_, err := q.db.Exec(ctx, deleteSetting, key)
|
||||
return err
|
||||
}
|
||||
|
||||
const getSetting = `-- name: GetSetting :one
|
||||
SELECT key, value, secret, updated_at FROM core.settings WHERE key = $1
|
||||
`
|
||||
|
||||
type GetSettingRow struct {
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value"`
|
||||
Secret bool `json:"secret"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetSetting(ctx context.Context, key string) (GetSettingRow, error) {
|
||||
row := q.db.QueryRow(ctx, getSetting, key)
|
||||
var i GetSettingRow
|
||||
err := row.Scan(
|
||||
&i.Key,
|
||||
&i.Value,
|
||||
&i.Secret,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listSettings = `-- name: ListSettings :many
|
||||
SELECT key, value, secret, updated_at FROM core.settings ORDER BY key
|
||||
`
|
||||
|
||||
type ListSettingsRow struct {
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value"`
|
||||
Secret bool `json:"secret"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListSettings(ctx context.Context) ([]ListSettingsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListSettingsRow
|
||||
for rows.Next() {
|
||||
var i ListSettingsRow
|
||||
if err := rows.Scan(
|
||||
&i.Key,
|
||||
&i.Value,
|
||||
&i.Secret,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertSetting = `-- name: UpsertSetting :one
|
||||
INSERT INTO core.settings (key, value, secret, updated_by)
|
||||
VALUES ($1, $2::jsonb, $3, $4)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value,
|
||||
secret = EXCLUDED.secret,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = NOW()
|
||||
RETURNING key, value, secret, updated_by, updated_at
|
||||
`
|
||||
|
||||
type UpsertSettingParams struct {
|
||||
Key string `json:"key"`
|
||||
Column2 []byte `json:"column_2"`
|
||||
Secret bool `json:"secret"`
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error) {
|
||||
row := q.db.QueryRow(ctx, upsertSetting,
|
||||
arg.Key,
|
||||
arg.Column2,
|
||||
arg.Secret,
|
||||
arg.UpdatedBy,
|
||||
)
|
||||
var i CoreSetting
|
||||
err := row.Scan(
|
||||
&i.Key,
|
||||
&i.Value,
|
||||
&i.Secret,
|
||||
&i.UpdatedBy,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -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"
|
||||
@@ -20,6 +23,7 @@ import (
|
||||
|
||||
type HTTPHandler struct {
|
||||
Platform *services.PlatformService
|
||||
Settings *services.SettingsService
|
||||
Validate *validator.Validate
|
||||
Metrics *observability.Metrics
|
||||
Readiness func(*gin.Context) map[string]any
|
||||
@@ -33,6 +37,11 @@ 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)
|
||||
api.DELETE("/instance/settings/:key", h.deleteInstanceSetting)
|
||||
api.POST("/bootstrap", h.bootstrap)
|
||||
api.GET("/organizations", h.organizations)
|
||||
api.POST("/organizations", h.createOrganization)
|
||||
@@ -73,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)
|
||||
@@ -111,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)
|
||||
@@ -151,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 {
|
||||
@@ -864,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 {
|
||||
@@ -1048,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
|
||||
@@ -1190,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,132 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// instancePublic serves unauthenticated bootstrap state to the login page.
|
||||
// It intentionally exposes only what the sign-in UI needs.
|
||||
func (h *HTTPHandler) instancePublic(c *gin.Context) {
|
||||
users, err := h.Settings.AuthUserCount(c.Request.Context())
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
signupEnabled := false
|
||||
if users > 0 {
|
||||
signupEnabled, err = h.Settings.GetBool(c.Request.Context(), "auth.signup_enabled")
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"signup_enabled": signupEnabled,
|
||||
"bootstrap_required": users == 0,
|
||||
"social_providers": managedSocialProviders(),
|
||||
})
|
||||
}
|
||||
|
||||
// managedSocialProviders mirrors the auth service's managed gate: OAuth only
|
||||
// exists when PRIMORA_MANAGED=true and the provider's client id is set.
|
||||
func managedSocialProviders() []string {
|
||||
if os.Getenv("PRIMORA_MANAGED") != "true" {
|
||||
return []string{}
|
||||
}
|
||||
providers := []string{}
|
||||
for _, p := range []struct{ name, envKey string }{
|
||||
{"github", "GITHUB_CLIENT_ID"},
|
||||
{"google", "GOOGLE_CLIENT_ID"},
|
||||
{"discord", "DISCORD_CLIENT_ID"},
|
||||
{"microsoft", "MICROSOFT_CLIENT_ID"},
|
||||
} {
|
||||
if os.Getenv(p.envKey) != "" {
|
||||
providers = append(providers, p.name)
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
// requireInstanceAdmin gates handlers on the platform admin role (better-auth
|
||||
// `user.role`), which is distinct from org/project roles.
|
||||
func (h *HTTPHandler) requireInstanceAdmin(c *gin.Context) bool {
|
||||
actor, ok := middleware.RequireActor(c)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !actor.IsUser() || !h.Settings.IsAdmin(c.Request.Context(), actor.AuthSubject) {
|
||||
apperrors.Abort(c, http.StatusForbidden, "admin_required", "instance admin role required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listInstanceSettings(c *gin.Context) {
|
||||
if !h.requireInstanceAdmin(c) {
|
||||
return
|
||||
}
|
||||
settings, err := h.Settings.ListResolved(c.Request.Context())
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) updateInstanceSetting(c *gin.Context) {
|
||||
if !h.requireInstanceAdmin(c) {
|
||||
return
|
||||
}
|
||||
key := c.Param("key")
|
||||
if _, ok := services.SettingSpecs[key]; !ok {
|
||||
apperrors.Abort(c, http.StatusNotFound, "unknown_setting", "unknown setting key")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Value json.RawMessage `json:"value" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || len(body.Value) == 0 {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "expected {\"value\": ...}")
|
||||
return
|
||||
}
|
||||
actor, _ := middleware.ActorFromContext(c)
|
||||
if err := h.Settings.Store(c.Request.Context(), key, body.Value, actor.Email); err != nil {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_value", err.Error())
|
||||
return
|
||||
}
|
||||
value, source, err := h.Settings.Resolve(c.Request.Context(), key)
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
spec := services.SettingSpecs[key]
|
||||
resp := gin.H{"key": key, "type": spec.Type, "secret": spec.Secret, "is_set": true, "source": source}
|
||||
if !spec.Secret {
|
||||
resp["value"] = value
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteInstanceSetting(c *gin.Context) {
|
||||
if !h.requireInstanceAdmin(c) {
|
||||
return
|
||||
}
|
||||
key := c.Param("key")
|
||||
if _, ok := services.SettingSpecs[key]; !ok {
|
||||
apperrors.Abort(c, http.StatusNotFound, "unknown_setting", "unknown setting key")
|
||||
return
|
||||
}
|
||||
if err := h.Settings.Delete(c.Request.Context(), key); err != nil {
|
||||
apperrors.Abort(c, http.StatusInternalServerError, "settings_unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -37,6 +37,9 @@ type AuthMiddleware struct {
|
||||
Redis *redis.Client
|
||||
Verifier *auth.Verifier
|
||||
RateLimits RateLimitConfig
|
||||
// RateLimitResolver overrides RateLimits with live instance settings when
|
||||
// set — it resolves "user" and "api_key" limits per request (cached).
|
||||
RateLimitResolver func(ctx context.Context, scope string) int
|
||||
}
|
||||
|
||||
type RateLimitConfig struct {
|
||||
@@ -44,6 +47,16 @@ type RateLimitConfig struct {
|
||||
UserPerMinute int
|
||||
}
|
||||
|
||||
func (m AuthMiddleware) rateLimit(ctx context.Context, scope string, fallback int) int {
|
||||
if m.RateLimitResolver == nil {
|
||||
return fallback
|
||||
}
|
||||
if v := m.RateLimitResolver(ctx, scope); v >= 0 {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func RequestID() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestID := c.Request.Header.Get("X-Request-ID")
|
||||
@@ -105,7 +118,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
||||
if actor.UserID != nil {
|
||||
userIdentity = actor.UserID.String()
|
||||
}
|
||||
if !m.enforceRateLimit(c, "user", userIdentity, m.RateLimits.UserPerMinute, "User rate limit exceeded") {
|
||||
if !m.enforceRateLimit(c, "user", userIdentity, m.rateLimit(c.Request.Context(), "user", m.RateLimits.UserPerMinute), "User rate limit exceeded") {
|
||||
return
|
||||
}
|
||||
c.Set(actorKey, actor)
|
||||
@@ -123,7 +136,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
||||
apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error())
|
||||
return
|
||||
}
|
||||
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.RateLimits.APIKeyPerMinute, "API key rate limit exceeded") {
|
||||
if !m.enforceRateLimit(c, "apikey", actor.APIKeyPrefix, m.rateLimit(c.Request.Context(), "api_key", m.RateLimits.APIKeyPerMinute), "API key rate limit exceeded") {
|
||||
return
|
||||
}
|
||||
if need := requiredAPIKeyScope(c.Request.Method, c.Request.URL.Path); !actor.HasScope(need) {
|
||||
|
||||
@@ -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 {
|
||||
@@ -56,3 +62,35 @@ func (r *CoreRepository) GetAPIKeyByPrefix(ctx context.Context, prefix string) (
|
||||
func (r *CoreRepository) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
|
||||
return r.queries.TouchAPIKey(ctx, id)
|
||||
}
|
||||
|
||||
// Auth-user lookups hit the better-auth tables in the public schema. They are
|
||||
// raw queries on purpose: sqlc only knows the core schema, and a view would
|
||||
// break when the backend migrates before the auth service has created its
|
||||
// tables.
|
||||
func (r *CoreRepository) GetAuthUserRole(ctx context.Context, authSubject string) (string, error) {
|
||||
var role string
|
||||
err := r.pool.QueryRow(ctx, `select coalesce(role, 'user') from public."user" where id = $1`, authSubject).Scan(&role)
|
||||
return role, err
|
||||
}
|
||||
|
||||
func (r *CoreRepository) CountAuthUsers(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
err := r.pool.QueryRow(ctx, `select count(*) from public."user"`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *CoreRepository) GetSetting(ctx context.Context, key string) (db.GetSettingRow, error) {
|
||||
return r.queries.GetSetting(ctx, key)
|
||||
}
|
||||
|
||||
func (r *CoreRepository) ListSettings(ctx context.Context) ([]db.ListSettingsRow, error) {
|
||||
return r.queries.ListSettings(ctx)
|
||||
}
|
||||
|
||||
func (r *CoreRepository) UpsertSetting(ctx context.Context, params db.UpsertSettingParams) (db.CoreSetting, error) {
|
||||
return r.queries.UpsertSetting(ctx, params)
|
||||
}
|
||||
|
||||
func (r *CoreRepository) DeleteSetting(ctx context.Context, key string) error {
|
||||
return r.queries.DeleteSetting(ctx, key)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/repositories"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/secrets"
|
||||
)
|
||||
|
||||
// SettingSpec describes a settable instance key: value type, the env var used
|
||||
// as fallback when no DB row exists, and a built-in default.
|
||||
type SettingSpec struct {
|
||||
Key string
|
||||
Type string // "bool" | "int" | "string"
|
||||
Secret bool
|
||||
EnvKey string
|
||||
Default string
|
||||
}
|
||||
|
||||
// SettingSpecs is the registry of keys manageable in-app. Env vars stay as
|
||||
// defaults — an app value wins when present.
|
||||
var SettingSpecs = map[string]SettingSpec{
|
||||
"auth.signup_enabled": {Key: "auth.signup_enabled", Type: "bool", EnvKey: "SIGNUP_ENABLED", Default: "false"},
|
||||
"mail.from": {Key: "mail.from", Type: "string", EnvKey: "MAIL_FROM", Default: "Primora <[email protected]>"},
|
||||
"mail.resend_api_key": {Key: "mail.resend_api_key", Type: "string", Secret: true, EnvKey: "RESEND_API_KEY"},
|
||||
"mail.smtp_host": {Key: "mail.smtp_host", Type: "string", EnvKey: "SMTP_HOST"},
|
||||
"mail.smtp_port": {Key: "mail.smtp_port", Type: "int", EnvKey: "SMTP_PORT", Default: "1025"},
|
||||
"mail.smtp_user": {Key: "mail.smtp_user", Type: "string", EnvKey: "SMTP_USER"},
|
||||
"mail.smtp_password": {Key: "mail.smtp_password", Type: "string", Secret: true, EnvKey: "SMTP_PASSWORD"},
|
||||
"mail.smtp_secure": {Key: "mail.smtp_secure", Type: "bool", EnvKey: "SMTP_SECURE", Default: "false"},
|
||||
"ratelimit.user_per_minute": {Key: "ratelimit.user_per_minute", Type: "int", EnvKey: "USER_RATE_LIMIT_PER_MINUTE", Default: "240"},
|
||||
"ratelimit.api_key_per_minute": {Key: "ratelimit.api_key_per_minute", Type: "int", EnvKey: "API_KEY_RATE_LIMIT_PER_MINUTE", Default: "600"},
|
||||
}
|
||||
|
||||
const settingsCacheTTL = 15 * time.Second
|
||||
|
||||
// resolvedSetting holds the stored string form: plaintext for normal keys,
|
||||
// base64 ciphertext for secrets — Resolve() decrypts on read.
|
||||
type resolvedSetting struct {
|
||||
value string
|
||||
secret bool
|
||||
}
|
||||
|
||||
// SettingsService resolves instance settings: DB row > env var > default.
|
||||
// Values cache briefly so per-request consumers (rate limits) stay cheap.
|
||||
type SettingsService struct {
|
||||
repo *repositories.CoreRepository
|
||||
encryptor *secrets.Encryptor
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]resolvedSetting
|
||||
cacheAt time.Time
|
||||
}
|
||||
|
||||
func NewSettingsService(repo *repositories.CoreRepository, encryptor *secrets.Encryptor) *SettingsService {
|
||||
return &SettingsService{repo: repo, encryptor: encryptor}
|
||||
}
|
||||
|
||||
// Resolve returns the effective string value plus its source
|
||||
// ("app" | "env" | "default"). Secret DB values are decrypted.
|
||||
func (s *SettingsService) Resolve(ctx context.Context, key string) (value, source string, err error) {
|
||||
spec, ok := SettingSpecs[key]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unknown setting: %s", key)
|
||||
}
|
||||
row, err := s.stored(ctx, key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if row != nil {
|
||||
raw := row.value
|
||||
if row.secret {
|
||||
raw, err = s.decrypt(raw)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("decrypt %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
return raw, "app", nil
|
||||
}
|
||||
if env := os.Getenv(spec.EnvKey); env != "" {
|
||||
return env, "env", nil
|
||||
}
|
||||
return spec.Default, "default", nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) GetBool(ctx context.Context, key string) (bool, error) {
|
||||
raw, _, err := s.Resolve(ctx, key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
v, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("setting %s is not a bool: %q", key, raw)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) GetInt(ctx context.Context, key string) (int, error) {
|
||||
raw, _, err := s.Resolve(ctx, key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("setting %s is not an int: %q", key, raw)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// RateLimitPerMinute is the hot-path helper for the auth middleware.
|
||||
func (s *SettingsService) RateLimitPerMinute(ctx context.Context, scope string) int {
|
||||
key := "ratelimit.user_per_minute"
|
||||
if scope == "api_key" {
|
||||
key = "ratelimit.api_key_per_minute"
|
||||
}
|
||||
v, err := s.GetInt(ctx, key)
|
||||
if err != nil {
|
||||
spec := SettingSpecs[key]
|
||||
fallback, _ := strconv.Atoi(spec.Default)
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ValidateAndStore type-checks the raw JSON value against the registry and
|
||||
// persists it. Secret values are encrypted before they touch the DB.
|
||||
func (s *SettingsService) Store(ctx context.Context, key string, raw json.RawMessage, updatedBy string) error {
|
||||
spec, ok := SettingSpecs[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown setting: %s", key)
|
||||
}
|
||||
plain, err := normalizeSetting(spec, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value := []byte(strconv.Quote(plain))
|
||||
if spec.Secret {
|
||||
ciphertext, err := s.encryptor.Encrypt([]byte(plain))
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt %s: %w", key, err)
|
||||
}
|
||||
value = []byte(strconv.Quote(base64.StdEncoding.EncodeToString(ciphertext)))
|
||||
}
|
||||
_, err = s.repo.UpsertSetting(ctx, db.UpsertSettingParams{
|
||||
Key: key,
|
||||
Column2: value,
|
||||
Secret: spec.Secret,
|
||||
UpdatedBy: &updatedBy,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidate()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) Delete(ctx context.Context, key string) error {
|
||||
if _, ok := SettingSpecs[key]; !ok {
|
||||
return fmt.Errorf("unknown setting: %s", key)
|
||||
}
|
||||
if err := s.repo.DeleteSetting(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the actor holds the platform admin role
|
||||
// (better-auth `user.role`), not an org/project role.
|
||||
func (s *SettingsService) IsAdmin(ctx context.Context, authSubject string) bool {
|
||||
role, err := s.repo.GetAuthUserRole(ctx, authSubject)
|
||||
return err == nil && role == "admin"
|
||||
}
|
||||
|
||||
func (s *SettingsService) AuthUserCount(ctx context.Context) (int64, error) {
|
||||
return s.repo.CountAuthUsers(ctx)
|
||||
}
|
||||
|
||||
// ListResolved returns every registered key with its effective state.
|
||||
// Secret values are masked — never returned.
|
||||
func (s *SettingsService) ListResolved(ctx context.Context) ([]ResolvedSetting, error) {
|
||||
stored, err := s.loadStored(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ResolvedSetting, 0, len(SettingSpecs))
|
||||
for _, key := range sortedSettingKeys() {
|
||||
spec := SettingSpecs[key]
|
||||
entry := ResolvedSetting{Key: key, Secret: spec.Secret, Type: spec.Type}
|
||||
if row, ok := stored[key]; ok {
|
||||
entry.IsSet = true
|
||||
entry.Source = "app"
|
||||
if !spec.Secret {
|
||||
entry.Value = json.RawMessage(strconv.Quote(row.value))
|
||||
}
|
||||
} else if env := os.Getenv(spec.EnvKey); env != "" {
|
||||
entry.Source = "env"
|
||||
if !spec.Secret {
|
||||
entry.Value = json.RawMessage(strconv.Quote(env))
|
||||
}
|
||||
} else {
|
||||
entry.Source = "default"
|
||||
if !spec.Secret {
|
||||
entry.Value = json.RawMessage(strconv.Quote(spec.Default))
|
||||
}
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type ResolvedSetting struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Secret bool `json:"secret"`
|
||||
IsSet bool `json:"is_set"`
|
||||
Source string `json:"source"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
func sortedSettingKeys() []string {
|
||||
keys := make([]string, 0, len(SettingSpecs))
|
||||
for k := range SettingSpecs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func (s *SettingsService) loadStored(ctx context.Context) (map[string]resolvedSetting, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cache != nil && time.Since(s.cacheAt) < settingsCacheTTL {
|
||||
return s.cache, nil
|
||||
}
|
||||
rows, err := s.repo.ListSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fresh := make(map[string]resolvedSetting, len(rows))
|
||||
for _, r := range rows {
|
||||
encoded, err := unwrapSettingValue(r.Value)
|
||||
if err != nil {
|
||||
continue // unreadable row — treat as unset rather than break consumers
|
||||
}
|
||||
fresh[r.Key] = resolvedSetting{value: encoded, secret: r.Secret}
|
||||
}
|
||||
s.cache = fresh
|
||||
s.cacheAt = time.Now()
|
||||
return fresh, nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) stored(ctx context.Context, key string) (*resolvedSetting, error) {
|
||||
all, err := s.loadStored(ctx)
|
||||
if err != nil {
|
||||
// cold-cache fallback: direct lookup
|
||||
row, err := s.repo.GetSetting(ctx, key)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := unwrapSettingValue(row.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resolvedSetting{value: encoded, secret: row.Secret}, nil
|
||||
}
|
||||
if row, ok := all[key]; ok {
|
||||
return &row, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) decrypt(ciphertextB64 string) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain, err := s.encryptor.Decrypt(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) invalidate() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cache = nil
|
||||
}
|
||||
|
||||
// unwrapSettingValue decodes the JSONB column: every stored value is a JSON
|
||||
// string — the plaintext for normal keys, base64 ciphertext for secrets.
|
||||
func unwrapSettingValue(raw []byte) (string, error) {
|
||||
var encoded string
|
||||
if err := json.Unmarshal(raw, &encoded); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// normalizeSetting validates raw JSON against the spec type and returns the
|
||||
// canonical string form stored/compared internally.
|
||||
func normalizeSetting(spec SettingSpec, raw json.RawMessage) (string, error) {
|
||||
switch spec.Type {
|
||||
case "bool":
|
||||
var v bool
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return "", fmt.Errorf("%s expects a boolean", spec.Key)
|
||||
}
|
||||
return strconv.FormatBool(v), nil
|
||||
case "int":
|
||||
var v int
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return "", fmt.Errorf("%s expects an integer", spec.Key)
|
||||
}
|
||||
if v < 0 {
|
||||
return "", fmt.Errorf("%s must be >= 0", spec.Key)
|
||||
}
|
||||
return strconv.Itoa(v), nil
|
||||
default:
|
||||
var v string
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return "", fmt.Errorf("%s expects a string", spec.Key)
|
||||
}
|
||||
if len(v) > 512 {
|
||||
return "", fmt.Errorf("%s exceeds 512 chars", spec.Key)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeSetting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
boolSpec := SettingSpecs["auth.signup_enabled"]
|
||||
intSpec := SettingSpecs["ratelimit.user_per_minute"]
|
||||
strSpec := SettingSpecs["mail.smtp_host"]
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
spec SettingSpec
|
||||
raw string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bool true", spec: boolSpec, raw: `true`, want: "true"},
|
||||
{name: "bool false", spec: boolSpec, raw: `false`, want: "false"},
|
||||
{name: "bool rejects string", spec: boolSpec, raw: `"true"`, wantErr: true},
|
||||
{name: "bool rejects int", spec: boolSpec, raw: `1`, wantErr: true},
|
||||
{name: "int accepts", spec: intSpec, raw: `120`, want: "120"},
|
||||
{name: "int zero allowed", spec: intSpec, raw: `0`, want: "0"},
|
||||
{name: "int rejects negative", spec: intSpec, raw: `-5`, wantErr: true},
|
||||
{name: "int rejects float", spec: intSpec, raw: `1.5`, wantErr: true},
|
||||
{name: "int rejects string", spec: intSpec, raw: `"120"`, wantErr: true},
|
||||
{name: "string accepts", spec: strSpec, raw: `"smtp.example.com"`, want: "smtp.example.com"},
|
||||
{name: "string rejects number", spec: strSpec, raw: `42`, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeSetting(tc.spec, json.RawMessage(tc.raw))
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortedSettingKeysDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := sortedSettingKeys()
|
||||
second := sortedSettingKeys()
|
||||
if len(first) != len(SettingSpecs) {
|
||||
t.Fatalf("expected %d keys, got %d", len(SettingSpecs), len(first))
|
||||
}
|
||||
for i := range first {
|
||||
if first[i] != second[i] {
|
||||
t.Fatalf("key order not deterministic at %d: %q vs %q", i, first[i], second[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,160 @@
|
||||
# @primora/cli
|
||||
|
||||
`primora` - command-line client for a Primora deployment, plus a local
|
||||
encrypted secrets vault and compose-stack operator commands.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build --workspace @primora/cli # → dist/cli.js
|
||||
node dist/cli.js login # or: npx primora login once published
|
||||
```
|
||||
|
||||
## Auth
|
||||
|
||||
- `primora login` - session sign-in against your deployment.
|
||||
- `primora login --api-key prm_…` - or export `PRIMORA_API_KEY`.
|
||||
|
||||
Context lives in `~/.config/primora/config.json`; `primora use` picks org +
|
||||
project interactively.
|
||||
|
||||
## Commands
|
||||
|
||||
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`,
|
||||
`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.
|
||||
Loaded 100 of 256 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user