mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-29 16:14:53 +00:00
Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a49de2db5 | ||
|
|
61a45cc48f | ||
|
|
d44bbcceda | ||
|
|
56c63d43cd | ||
|
|
6d758c0089 | ||
|
|
361538aabc | ||
|
|
5eec80bbca | ||
|
|
40c189c431 | ||
|
|
bc8b165ad9 | ||
|
|
61f0974c61 | ||
|
|
769d04471f | ||
|
|
da294aaa2b | ||
|
|
1226d44872 | ||
|
|
ac4ca9a3ae | ||
|
|
597db3287e | ||
|
|
bbc57f9c0d | ||
|
|
9ab9b550d1 | ||
|
|
49ca9067a9 | ||
|
|
805a58e32f | ||
|
|
d09b0a1c2d | ||
|
|
92b1810c07 | ||
|
|
3ad37c190b | ||
|
|
cb56cedb1f | ||
|
|
20b4ea6a07 | ||
|
|
0511e5754b | ||
|
|
3800e736d0 | ||
|
|
7548ea75d4 | ||
|
|
14d0762245 | ||
|
|
a990989d34 | ||
|
|
3b9ea333ad | ||
|
|
86d612ec93 | ||
|
|
0c5d4a72b2 | ||
|
|
61e8c696c9 | ||
|
|
f6751b70d4 | ||
|
|
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
+41
-1
@@ -29,6 +29,14 @@ BETTER_AUTH_SECRET=change-me-super-long-better-auth-secret
|
|||||||
BETTER_AUTH_URL=http://localhost/auth
|
BETTER_AUTH_URL=http://localhost/auth
|
||||||
# Comma-separated emails promoted to auth admin on boot (Authentication → Users panel)
|
# Comma-separated emails promoted to auth admin on boot (Authentication → Users panel)
|
||||||
AUTH_ADMIN_EMAILS=
|
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
|
COOKIE_DOMAIN=localhost
|
||||||
|
|
||||||
FRONTEND_PORT=3000
|
FRONTEND_PORT=3000
|
||||||
@@ -42,14 +50,46 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1
|
|||||||
BACKEND_INTERNAL_URL=http://backend:8080
|
BACKEND_INTERNAL_URL=http://backend:8080
|
||||||
BACKEND_STORAGE_ROOT=/data/storage
|
BACKEND_STORAGE_ROOT=/data/storage
|
||||||
|
|
||||||
|
# Object storage driver: "local" (default, uses BACKEND_STORAGE_ROOT) or "s3".
|
||||||
|
# For s3, point S3_ENDPOINT at any S3-compatible API (AWS, MinIO, Garage, R2).
|
||||||
|
BACKEND_STORAGE_DRIVER=local
|
||||||
|
#S3_ENDPOINT=http://minio:9000
|
||||||
|
#S3_REGION=us-east-1
|
||||||
|
#S3_BUCKET=primora-objects
|
||||||
|
#S3_ACCESS_KEY_ID=
|
||||||
|
#S3_SECRET_ACCESS_KEY=
|
||||||
|
#S3_PREFIX=
|
||||||
|
#S3_PATH_STYLE=true
|
||||||
|
# Client-facing S3 endpoint for presigned URLs — set when S3_ENDPOINT is an
|
||||||
|
# internal address (e.g. http://minio:9000 inside compose) that browsers
|
||||||
|
# can't reach. Presigned URLs are valid only at the host they were minted for.
|
||||||
|
#S3_PUBLIC_ENDPOINT=https://objects.example.com
|
||||||
|
|
||||||
|
# Functions execution. "exec" runs the runtime binary (bun/deno) directly on
|
||||||
|
# the backend host — simple, but function code inherits backend privileges.
|
||||||
|
# "docker" runs each invoke in an ephemeral locked-down container (no
|
||||||
|
# network, read-only fs, 128 MiB mem, non-root) and requires the Docker
|
||||||
|
# socket mounted into the backend plus the runtime images pulled.
|
||||||
|
#FUNCTIONS_DRIVER=exec
|
||||||
|
#FUNCTIONS_RUNTIME=auto
|
||||||
|
#FUNCTIONS_TIMEOUT_SECONDS=30
|
||||||
|
#FUNCTIONS_MAX_OUTPUT_BYTES=65536
|
||||||
|
|
||||||
|
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
|
||||||
|
# values win over these env vars; deleting the in-app value reverts here.
|
||||||
|
# With no transport configured (no Resend key and no SMTP_HOST) accounts are
|
||||||
|
# verified automatically and no verification mail is attempted.
|
||||||
|
# The dev overlay (docker compose -f docker-compose.yml -f docker-compose.dev.yml)
|
||||||
|
# runs Mailpit — then SMTP_HOST=mailpit catches all outgoing mail at :8025.
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
MAIL_FROM=Primora <[email protected]>
|
MAIL_FROM=Primora <[email protected]>
|
||||||
SMTP_HOST=mailpit
|
SMTP_HOST=
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
|
|
||||||
|
# OAuth keys only take effect when PRIMORA_MANAGED=true (managed tier).
|
||||||
GITHUB_CLIENT_ID=
|
GITHUB_CLIENT_ID=
|
||||||
GITHUB_CLIENT_SECRET=
|
GITHUB_CLIENT_SECRET=
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
github: Dvorinka
|
||||||
+195
-16
@@ -1,25 +1,54 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Triggered by semver tags: git tag v0.3.0 && git push --tags
|
# Triggered two ways:
|
||||||
# Tag must match the root package.json version.
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["CI"]
|
||||||
|
types: [completed]
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
env:
|
|
||||||
VERSION: ${{ github.ref_name }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
name: Verify tag
|
name: Verify version
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.check.outputs.version }}
|
||||||
|
needed: ${{ steps.check.outputs.needed }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Check tag matches package versions
|
||||||
|
if: steps.check.outputs.needed == 'true'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.check.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
PKG=$(node -p "require('./package.json').version")
|
PKG=$(node -p "require('./package.json').version")
|
||||||
DESKTOP_PKG=$(node -p "require('./apps/desktop/package.json').version")
|
DESKTOP_PKG=$(node -p "require('./apps/desktop/package.json').version")
|
||||||
@@ -30,16 +59,20 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
images:
|
images:
|
||||||
name: Build + push ${{ matrix.app }}
|
name: Build + push ${{ matrix.app }} (${{ matrix.platform }})
|
||||||
needs: verify
|
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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
app: [backend, auth, frontend]
|
app: [backend, auth, frontend]
|
||||||
|
platform: [amd64, arm64]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: docker/setup-qemu-action@v3
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
# ghcr.io requires lowercase repo paths; the owner login may be mixed-case.
|
# ghcr.io requires lowercase repo paths; the owner login may be mixed-case.
|
||||||
- name: Compute lowercase GHCR owner
|
- name: Compute lowercase GHCR owner
|
||||||
@@ -54,17 +87,43 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: apps/${{ matrix.app }}/Dockerfile
|
file: apps/${{ matrix.app }}/Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ needs.verify.outputs.version }}-${{ matrix.platform }}
|
||||||
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:${{ env.VERSION }}
|
cache-from: type=gha,scope=${{ matrix.app }}-${{ matrix.platform }}
|
||||||
ghcr.io/${{ env.GHCR_OWNER }}/primora-${{ matrix.app }}:latest
|
cache-to: type=gha,mode=max,scope=${{ matrix.app }}-${{ matrix.platform }}
|
||||||
cache-from: type=gha,scope=${{ matrix.app }}
|
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.app }}
|
# 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:
|
cli-binaries:
|
||||||
name: Compile ${{ matrix.name }}
|
name: Compile ${{ matrix.name }}
|
||||||
needs: verify
|
needs: verify
|
||||||
|
if: needs.verify.outputs.needed == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -94,6 +153,7 @@ jobs:
|
|||||||
desktop:
|
desktop:
|
||||||
name: Desktop bundle (${{ matrix.name }})
|
name: Desktop bundle (${{ matrix.name }})
|
||||||
needs: verify
|
needs: verify
|
||||||
|
if: needs.verify.outputs.needed == 'true'
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -114,6 +174,11 @@ jobs:
|
|||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
- name: Build Tauri bundles
|
- name: Build Tauri bundles
|
||||||
|
# Updater artifacts are minisign-signed; without the key the build
|
||||||
|
# still succeeds but emits no .sig files (updater can't verify them).
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
run: npm run bundle --workspace @primora/desktop
|
run: npm run bundle --workspace @primora/desktop
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -123,13 +188,17 @@ jobs:
|
|||||||
apps/desktop/src-tauri/target/release/bundle/**/*.deb
|
apps/desktop/src-tauri/target/release/bundle/**/*.deb
|
||||||
apps/desktop/src-tauri/target/release/bundle/**/*.rpm
|
apps/desktop/src-tauri/target/release/bundle/**/*.rpm
|
||||||
apps/desktop/src-tauri/target/release/bundle/**/*.dmg
|
apps/desktop/src-tauri/target/release/bundle/**/*.dmg
|
||||||
|
apps/desktop/src-tauri/target/release/bundle/**/*.app.tar.gz
|
||||||
apps/desktop/src-tauri/target/release/bundle/**/*.msi
|
apps/desktop/src-tauri/target/release/bundle/**/*.msi
|
||||||
apps/desktop/src-tauri/target/release/bundle/**/*.exe
|
apps/desktop/src-tauri/target/release/bundle/**/*.exe
|
||||||
|
apps/desktop/src-tauri/target/release/bundle/**/*.nsis.zip
|
||||||
|
apps/desktop/src-tauri/target/release/bundle/**/*.sig
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
npm-packages:
|
npm-packages:
|
||||||
name: Pack npm tarballs
|
name: Pack npm tarballs
|
||||||
needs: verify
|
needs: verify
|
||||||
|
if: needs.verify.outputs.needed == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -151,8 +220,11 @@ jobs:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
name: GitHub 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
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.verify.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
# build-push-action uploads "*.dockerbuild" metadata artifacts that
|
# build-push-action uploads "*.dockerbuild" metadata artifacts that
|
||||||
@@ -171,6 +243,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: npm-tarballs
|
name: npm-tarballs
|
||||||
path: dist-release
|
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
|
- name: Extract changelog section
|
||||||
run: |
|
run: |
|
||||||
awk -v ver="${VERSION#v}" '
|
awk -v ver="${VERSION#v}" '
|
||||||
@@ -179,6 +261,48 @@ jobs:
|
|||||||
flag
|
flag
|
||||||
' CHANGELOG.md > notes.md
|
' CHANGELOG.md > notes.md
|
||||||
[ -s notes.md ] || echo "Release ${VERSION}" > notes.md
|
[ -s notes.md ] || echo "Release ${VERSION}" > notes.md
|
||||||
|
# Tauri updater manifest: one platform entry per signed updater
|
||||||
|
# artifact (AppImage.sig / app.tar.gz.sig / nsis.zip.sig). Missing
|
||||||
|
# sigs mean TAURI_SIGNING_PRIVATE_KEY isn't set — then there is no
|
||||||
|
# manifest and no update, which is correct (never ship unsigned).
|
||||||
|
- name: Build updater manifest (latest.json)
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, os, glob
|
||||||
|
base = "https://github.com/Dvorinka/Primora/releases/download/%s" % os.environ["VERSION"]
|
||||||
|
platforms = {}
|
||||||
|
for sig in glob.glob("dist-release/**/*.sig", recursive=True):
|
||||||
|
asset = sig[:-4]
|
||||||
|
name = os.path.basename(asset)
|
||||||
|
if name.endswith(".AppImage"):
|
||||||
|
plat = "linux-x86_64"
|
||||||
|
elif name.endswith(".app.tar.gz"):
|
||||||
|
plat = "darwin-aarch64" if "aarch64" in name else "darwin-x86_64"
|
||||||
|
if "aarch64" not in name and "x86_64" not in name:
|
||||||
|
plat = "darwin-aarch64" # macos-latest is arm64
|
||||||
|
elif name.endswith(".nsis.zip"):
|
||||||
|
plat = "windows-x86_64"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
platforms[plat] = {
|
||||||
|
"signature": open(sig).read().strip(),
|
||||||
|
"url": f"{base}/{name}",
|
||||||
|
}
|
||||||
|
manifest = {
|
||||||
|
"version": os.environ["VERSION"],
|
||||||
|
"notes": "See the release notes.",
|
||||||
|
"pub_date": __import__("datetime").datetime.now(
|
||||||
|
__import__("datetime").timezone.utc
|
||||||
|
).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"platforms": platforms,
|
||||||
|
}
|
||||||
|
if platforms:
|
||||||
|
with open("dist-release/latest.json", "w") as f:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
print("latest.json:", ", ".join(platforms))
|
||||||
|
else:
|
||||||
|
print("no signed updater artifacts — skipping latest.json")
|
||||||
|
PY
|
||||||
- name: Create release
|
- name: Create release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -188,6 +312,61 @@ jobs:
|
|||||||
mapfile -t files < <(find dist-release -type f)
|
mapfile -t files < <(find dist-release -type f)
|
||||||
[ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; }
|
[ ${#files[@]} -gt 0 ] || { echo "no release assets found"; exit 1; }
|
||||||
gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}"
|
gh release create "$VERSION" --title "$VERSION" --notes-file notes.md "${files[@]}"
|
||||||
|
# The manifest must be fetchable at /releases/latest/download/latest.json
|
||||||
|
if [ -f dist-release/latest.json ]; then
|
||||||
|
gh release upload "$VERSION" dist-release/latest.json --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Software bill of materials — CycloneDX JSON per published image plus
|
||||||
|
# one for the source tree (npm + Go modules). Attached to the release
|
||||||
|
# next to the binaries; downstream consumers can verify provenance.
|
||||||
|
sbom:
|
||||||
|
name: SBOMs
|
||||||
|
needs: [verify, release]
|
||||||
|
if: needs.verify.outputs.needed == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: read
|
||||||
|
# contents: write — the job uploads SBOM assets onto the release.
|
||||||
|
contents: write
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.verify.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# ghcr.io requires lowercase repo paths; the owner login may be mixed-case.
|
||||||
|
- 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 }}
|
||||||
|
# anchore/sbom-action does not create the output directory.
|
||||||
|
- run: mkdir -p dist-release
|
||||||
|
- uses: anchore/sbom-action@v0
|
||||||
|
with:
|
||||||
|
image: ghcr.io/${{ env.GHCR_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/${{ env.GHCR_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/${{ env.GHCR_OWNER }}/primora-frontend:${{ needs.verify.outputs.version }}
|
||||||
|
format: cyclonedx-json
|
||||||
|
output-file: dist-release/sbom-frontend.cdx.json
|
||||||
|
- uses: anchore/sbom-action@v0
|
||||||
|
with:
|
||||||
|
path: .
|
||||||
|
format: cyclonedx-json
|
||||||
|
output-file: dist-release/sbom-source.cdx.json
|
||||||
|
- name: Attach SBOMs to the release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: gh release upload "$VERSION" dist-release/sbom-*.cdx.json --clobber
|
||||||
|
|
||||||
npm-publish:
|
npm-publish:
|
||||||
name: Publish to npm
|
name: Publish to npm
|
||||||
|
|||||||
@@ -39,3 +39,5 @@ infra/mailpit
|
|||||||
# Generated code-graph artifacts
|
# Generated code-graph artifacts
|
||||||
graphify-out/
|
graphify-out/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
# Local brag/launch-video output
|
||||||
|
brag-output/
|
||||||
+131
@@ -2,6 +2,137 @@
|
|||||||
|
|
||||||
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
|
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **App logins** - a username and password bound to a stable app id, in the local vault and in the project vault. The app id is not a host or a port, so `localhost:3000` can change without losing the account. `primora logins set|get|list|rm` (`--remote` for the project). The dashboard Vault page and the desktop vault window manage both.
|
||||||
|
- **Agent reads** - `primora_vault_get`, `primora_secrets_get`, `primora_logins_get`, `primora_logins_set`, and `primora_logins_list`. An agent fetches one named value when it needs to sign in. Lists still omit passwords. Project reveals stay audit-logged. Do not paste the value into chat or a `.env` file.
|
||||||
|
|
||||||
|
## [0.8.5] - 2026-09-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Content column centers on wide screens** — `.page` was capped at 72rem but left-aligned, so above ~1200px the dashboard hugged the left with a growing dead zone on the right. It now centers (`margin-inline: auto`) while staying full-width below the cap.
|
||||||
|
|
||||||
|
## [0.8.4] - 2026-09-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Slugs auto-derive from names** — typing an organization, project, bucket, or collection name now fills its slug (`TDvorak` → `tdvorak`); hand-editing the slug stops the derivation, and manual edits are sanitized as you type. Edit forms stay manual.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Installer URL prompt was invisible under `curl | bash`** — `exec 9</dev/tty 2>/dev/null` sent the whole shell's stderr to `/dev/null` permanently, so the `read` prompt rendered nowhere while the script blocked on input. The prompt now reads `/dev/tty` directly with a quiet probe; no-tty runs default silently.
|
||||||
|
- **Installer fetches fail fast** — the compose-file and source-tarball `curl` calls now carry connect/max-time timeouts plus retries, so a stalled GitHub connection errors within seconds naming the file instead of hanging forever.
|
||||||
|
|
||||||
|
## [0.8.3] - 2026-09-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Install script asks for the public URL** — `install.sh` now prompts for where you'll open the app and accepts a full URL: `https://primora.example.com` (TLS at your proxy, hints printed at the end), `primora.example.com`, or `192.168.1.50:8085` (the port becomes `NGINX_PORT`). The answer rewrites every public URL var in `.env` and `COOKIE_DOMAIN` for real DNS names. The prompt reads the controlling terminal so it appears under `curl | bash`; no tty (CI, cron) falls back to `localhost`. Non-interactive via `PUBLIC_URL=`; legacy `DOMAIN`/`NGINX_PORT` envs still work; reruns over an existing `.env` skip the question.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Auth origins behind external TLS terminators** — trusted origins now accept both URL schemes on the serving host. Verified live over a Cloudflare quick tunnel: a real browser session cookie + `https://*.trycloudflare.com` origin was 403'd on v0.8.2 (nginx reports `X-Forwarded-Proto: http` inbound); the same request now signs in cleanly while cross-site origins still 403.
|
||||||
|
|
||||||
|
## [0.8.2] - 2026-09-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Self-hosted sign-up works on any origin** — trusted origins are now derived from the request's public host (forwarded by nginx), so browsing the instance via a LAN IP, hostname, or custom port no longer fails every auth POST with a 403. Static `VITE_APP_URL`/`AUTH_BASE_URL` origins and `BETTER_AUTH_TRUSTED_ORIGINS` still apply.
|
||||||
|
- **Sign-up/sign-in failures are surfaced, not swallowed** — better-auth resolves `{ data, error }` instead of throwing, so a failed request previously showed "Account created — signing you in" and bounced back to the bootstrap screen. The real server error (invalid origin, rate limit, disabled sign-up) is now displayed.
|
||||||
|
- **Mail probe can no longer break auth** — `hasMailTransport` degrades to "no transport" instead of throwing when `core.settings` is unreadable, and the no-transport startup backfill is best-effort. Previously, auth could crash-loop while the backend was still migrating on a fresh volume — every sign-up 502'd during the window.
|
||||||
|
|
||||||
|
## [0.8.1] - 2026-09-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **SMTP-less instances no longer involve email at all** — when no mail transport is configured (no Resend key, no `SMTP_HOST`), new accounts are created `emailVerified=true`, the verification send is skipped entirely, and existing unverified users are marked verified on startup. `.env.example` no longer defaults `SMTP_HOST` to the dev-only `mailpit` service, which left production deploys pointing at a transport that does not exist.
|
||||||
|
|
||||||
|
## [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
|
## [0.6.0] - 2026-09-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+35
-3
@@ -9,9 +9,24 @@ cp .env.example .env
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
For native development run only the infrastructure in Docker
|
For native development run only the infrastructure in Docker and start the
|
||||||
(`docker compose up -d postgres dragonfly mailpit`) and start the three
|
three services yourself:
|
||||||
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
|
## Workflow
|
||||||
|
|
||||||
@@ -29,6 +44,23 @@ services yourself.
|
|||||||
If you changed `apps/backend/openapi/openapi.yaml`, run
|
If you changed `apps/backend/openapi/openapi.yaml`, run
|
||||||
`npm run generate:client`. Commit the regenerated output with your change.
|
`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
|
## Conventions
|
||||||
|
|
||||||
- **Generated code is never hand-edited** — `packages/api-client/src/generated`
|
- **Generated code is never hand-edited** — `packages/api-client/src/generated`
|
||||||
|
|||||||
+37
-9
@@ -37,11 +37,18 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
|||||||
| `DRAGONFLY_URL` | backend → cache DSN | yes |
|
| `DRAGONFLY_URL` | backend → cache DSN | yes |
|
||||||
| `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes |
|
| `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes |
|
||||||
| `BETTER_AUTH_SECRET` | auth session/signing secret | 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_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
|
||||||
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
|
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
|
||||||
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
|
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — informational only; the shipped image resolves API/auth URLs same-origin at runtime, so no rebuild is needed when the host changes | yes |
|
||||||
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container | yes |
|
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container (local driver) | yes |
|
||||||
|
| `BACKEND_STORAGE_DRIVER` | `local` (default) or `s3` — S3-compatible object storage | no |
|
||||||
|
| `S3_ENDPOINT` / `S3_REGION` / `S3_BUCKET` | S3 API endpoint (empty endpoint → AWS `s3.<region>.amazonaws.com`), region, bucket | with `s3` |
|
||||||
|
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 credentials | with `s3` |
|
||||||
|
| `S3_PREFIX` / `S3_PATH_STYLE` | optional key prefix; path-style URLs (default `true`, needed by MinIO/Garage — set `false` for AWS virtual-hosted) | no |
|
||||||
|
| `S3_PUBLIC_ENDPOINT` | client-facing S3 endpoint used for presigned URLs when `S3_ENDPOINT` is internal-only (e.g. `http://minio:9000`); signatures bind the public host | no |
|
||||||
|
| `FUNCTIONS_DRIVER` | Function execution driver: `exec` (default — runtime binary on the backend host; code runs with backend privileges) or `docker` — each invoke runs in an ephemeral locked-down container (no network, read-only fs, 128 MiB, non-root). Docker mode needs `/var/run/docker.sock` mounted into the backend (see the commented volume in `docker-compose.yml`) and the runtime images pulled (`oven/bun`, `denoland/deno`). Source code is capped at 256 KiB under the docker driver | no |
|
||||||
|
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` / `FUNCTIONS_MAX_OUTPUT_BYTES` | Functions: runtime binary (`auto` resolves `bun`/`deno` — exec driver only; docker driver uses the matching official image), per-run timeout (default 30 s), captured-output cap (default 64 KiB) | no |
|
||||||
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
|
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
|
||||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
|
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
|
||||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
|
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
|
||||||
@@ -74,8 +81,12 @@ labels:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Once TLS is live, update `VITE_APP_URL`, `VITE_AUTH_BASE_URL`,
|
Once TLS is live, update `VITE_APP_URL`, `VITE_AUTH_BASE_URL`,
|
||||||
`AUTH_BASE_URL`, `BETTER_AUTH_URL`, and `COOKIE_DOMAIN` to the https origin
|
`AUTH_BASE_URL`, `BETTER_AUTH_URL`, `BACKEND_PUBLIC_URL`, and
|
||||||
and rebuild the frontend image (`docker compose build frontend`).
|
`COOKIE_DOMAIN` to the https origin and restart the stack
|
||||||
|
(`docker compose up -d`). The frontend resolves API/auth URLs
|
||||||
|
same-origin, so no image rebuild is needed — HTTPS is still required for
|
||||||
|
PWA install, service workers, and other secure-context features on
|
||||||
|
non-localhost origins.
|
||||||
|
|
||||||
## SMTP / email
|
## SMTP / email
|
||||||
|
|
||||||
@@ -127,10 +138,27 @@ docker compose up -d # 3. rolling recreate; backend migrate
|
|||||||
|
|
||||||
## Storage constraint
|
## Storage constraint
|
||||||
|
|
||||||
Object storage is the **local filesystem** (`backend_storage` volume). There
|
Object storage defaults to the **local filesystem** (`backend_storage`
|
||||||
is no S3-compatible backend yet — the volume must be on durable, backed-up
|
volume) — durable, backed-up disk on a single host. For multi-node or
|
||||||
disk, and a single host serves all objects. Plan capacity accordingly;
|
external object stores set `BACKEND_STORAGE_DRIVER=s3` + the `S3_*`
|
||||||
multi-node deployments need an external object store first.
|
variables above (AWS, MinIO, Garage, R2 all work).
|
||||||
|
|
||||||
|
## Functions trust model
|
||||||
|
|
||||||
|
Two drivers, two trust levels:
|
||||||
|
|
||||||
|
- **`exec` (default)** — function code runs as a child process of the
|
||||||
|
backend: same host, same privileges, no sandbox. Treat every project
|
||||||
|
member who can edit a function as holding a shell on the backend host.
|
||||||
|
Appropriate for single-team self-hosted deployments.
|
||||||
|
- **`docker`** — each invoke runs in an ephemeral container: no network,
|
||||||
|
read-only rootfs, 128 MiB memory, half a CPU, 64 pids, non-root, all
|
||||||
|
capabilities dropped. Requires the Docker socket mounted into the
|
||||||
|
backend (commented volume in `docker-compose.yml`) — note the socket is
|
||||||
|
itself root-equivalent on the host, so this isolates *function code*,
|
||||||
|
not operators. Source code capped at 256 KiB. For hostile multi-tenant
|
||||||
|
workloads, a dedicated microVM runtime (Firecracker/gVisor) remains the
|
||||||
|
roadmap item.
|
||||||
|
|
||||||
## Observability
|
## Observability
|
||||||
|
|
||||||
|
|||||||
+29
-2
@@ -61,10 +61,37 @@ The important ones:
|
|||||||
| `POSTGRES_*` / `DATABASE_URL` | Postgres connection |
|
| `POSTGRES_*` / `DATABASE_URL` | Postgres connection |
|
||||||
| `DRAGONFLY_URL` | Cache/rate-limit store |
|
| `DRAGONFLY_URL` | Cache/rate-limit store |
|
||||||
| `SMTP_*` / `MAIL_FROM` | Transactional email (Mailpit in dev, or Resend via `RESEND_API_KEY`) |
|
| `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 |
|
| `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 |
|
||||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling, `0` disables |
|
| `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 |
|
| `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
|
## Verification
|
||||||
|
|
||||||
```bash
|
```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. App logins (username, password, stable app id) live in both, and an agent can read one named value when it needs to sign in.
|
||||||
|
- **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)
|
Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite)
|
||||||
@@ -10,153 +69,53 @@ Browser ──▶ Nginx ──▶ Frontend (SolidJS + Vite)
|
|||||||
──▶ Local filesystem object storage
|
──▶ Local filesystem object storage
|
||||||
```
|
```
|
||||||
|
|
||||||
## What you get
|
## Quick Start
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
||||||
Prerequisites: Docker with the Compose plugin.
|
Prerequisites: Docker with the Compose plugin.
|
||||||
|
|
||||||
### One-liner (no clone)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | 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.
|
Installs into `./primora`, asks one question — the URL you'll open the app on
|
||||||
Non-interactive — configure with env vars:
|
(domain, IP, optional port) — generates secrets, and starts the stack.
|
||||||
|
Non-interactive: `PUBLIC_URL=https://primora.example.com bash install.sh`.
|
||||||
```bash
|
Or from a clone:
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Dvorinka/Primora.git && cd Primora
|
git clone https://github.com/Dvorinka/Primora.git && cd Primora
|
||||||
./scripts/setup.sh # creates .env, generates secrets, starts the stack
|
./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
|
## Configuration
|
||||||
cp .env.example .env # fill JWT_SECRET and BETTER_AUTH_SECRET
|
|
||||||
docker compose up -d # builds the three app images from source
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
## Ecosystem
|
||||||
docker pull ghcr.io/dvorinka/primora-{backend,auth,frontend}:latest
|
|
||||||
docker compose up -d --no-build
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open `http://localhost`. For local email capture, add the dev overlay
|
- **[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:*`).
|
||||||
(`-f docker-compose.dev.yml`) and Mailpit is at `http://localhost/mailpit/`.
|
- **[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).
|
||||||
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.
|
- **PWA** - the dashboard is installable on mobile and desktop browsers.
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [QUICK_START.md](QUICK_START.md) — setup, configuration, troubleshooting
|
- [QUICK_START.md](QUICK_START.md) - setup, configuration, troubleshooting
|
||||||
- [project_backend.md](project_backend.md) / [project_frontend.md](project_frontend.md) — original design specs
|
- [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
|
||||||
- `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
|
## License
|
||||||
|
|
||||||
|
|||||||
+173
-28
@@ -123,8 +123,11 @@ Follow-ups:
|
|||||||
|
|
||||||
- Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run
|
- Verify the macOS/Windows desktop bundles produced by the `v0.5.0` CI run
|
||||||
(host-verified on Linux only).
|
(host-verified on Linux only).
|
||||||
- Desktop bundle signing + auto-updater — unsigned artifacts today; add when
|
- ~~Desktop bundle signing + auto-updater~~ ✅ — `tauri-plugin-updater`
|
||||||
distribution matures.
|
wired: signed updater artifacts (`createUpdaterArtifacts`), minisign
|
||||||
|
keypair, `latest.json` manifest assembled on release, in-app
|
||||||
|
"Update & restart" on the connect screen. macOS/Windows OS-level code
|
||||||
|
signing (notarization/Authenticode) still open — needs paid certs.
|
||||||
|
|
||||||
## Phase 6 — Production hardening (v0.6.0) ✅
|
## Phase 6 — Production hardening (v0.6.0) ✅
|
||||||
|
|
||||||
@@ -185,8 +188,9 @@ release pipeline with images + binaries + desktop bundles.
|
|||||||
(auto-registers from ingest).
|
(auto-registers from ingest).
|
||||||
- ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres →
|
- ~~**DB-to-DB links**~~ ✅ — implemented and verified live (postgres →
|
||||||
dragonfly transfer, key templating, TTL).
|
dragonfly transfer, key templating, TTL).
|
||||||
- ~~**External object storage**~~ ✅ — constraint documented in
|
- ~~**External object storage**~~ ✅ — `BACKEND_STORAGE_DRIVER=s3` shipped
|
||||||
DEPLOYMENT_GUIDE.md / PRODUCTION_READINESS.md; S3 backend deferred.
|
(Phase 10): any S3-compatible API + presigned URLs; local disk remains
|
||||||
|
the default.
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
@@ -205,8 +209,10 @@ release pipeline with images + binaries + desktop bundles.
|
|||||||
- ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via
|
- ~~**arm64 images**~~ ✅ — release builds `linux/amd64,linux/arm64` via
|
||||||
QEMU + buildx.
|
QEMU + buildx.
|
||||||
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle
|
- Carried forward from Phase 5 follow-ups: macOS/Windows bundle
|
||||||
verification, signing + auto-updater.
|
verification; updater + minisign signing shipped (OS-level signing
|
||||||
- Optional: SBOM + provenance attestation on release images.
|
remains open).
|
||||||
|
- ~~Optional: SBOM~~ ✅ — CycloneDX SBOMs per published image + source
|
||||||
|
tree, attached to every release (`anchore/sbom-action`).
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
|
|
||||||
@@ -263,32 +269,171 @@ dashboard's live feed off the same stream.
|
|||||||
`auth-client` now resolves `/auth` against `window.location.origin`,
|
`auth-client` now resolves `/auth` against `window.location.origin`,
|
||||||
preserving same-origin defaults without breaking split-origin dev.
|
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
|
## Future phases — candidates
|
||||||
|
|
||||||
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
||||||
|
|
||||||
- **Phase 8 — Client realtime SDK** — `@primora/client` channel
|
- **Phase 9 — Functions** — ✅ shipped: `core.functions` +
|
||||||
subscriptions (`channel('documents').on('created', …)`), presence,
|
`core.function_runs`, bun/deno exec runner (stdin payload, env injection,
|
||||||
and broadcast over the Phase 7 stream. Supabase-realtime parity is a
|
capped output, timeout), invoke API + dashboard page. Triggers shipped:
|
||||||
protocol question, not a transport one.
|
scheduled jobs (`function_id` on a job), inbound hooks (`mode:
|
||||||
- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers.
|
"function"`), and domain events (`event_pattern`, e.g. `document.*`).
|
||||||
Deno or Bun isolates in a sidecar; secrets via env, payload via stdin.
|
Runs record trigger source (`manual`/`schedule`/`hook`/`event`).
|
||||||
The job runner already supplies the scheduling half.
|
Isolation shipped: `FUNCTIONS_DRIVER=docker` runs every invoke in an
|
||||||
- **Phase 10 — Storage backends** — S3-compatible object storage behind
|
ephemeral locked-down container (no network, read-only fs, non-root).
|
||||||
the existing bucket abstraction (deferred from Phase 6), plus
|
Remaining candidate: Firecracker/gVisor microVMs for hostile
|
||||||
presigned-URL uploads for large files.
|
multi-tenant workloads — docker socket trust is documented.
|
||||||
- **Phase 11 — Generated REST per collection** — PostgREST-style
|
- **Phase 10 — Storage backends** — ✅ shipped: `BACKEND_STORAGE_DRIVER=s3`
|
||||||
auto-CRUD + filtering over Collections documents; a clean read layer
|
+ `S3_*` env vars, stdlib SigV4, verified against a real S3 server.
|
||||||
on data that already exists.
|
Presigned upload/download URLs shipped: `POST
|
||||||
- **Notifications** — alert rules on telemetry (error-rate thresholds,
|
/buckets/:id/object-presigns` mints SigV4 query-signed GET/PUT URLs
|
||||||
heartbeat silence) fanning out through the webhook dispatcher.
|
(`S3_PUBLIC_ENDPOINT` rewrites the client-facing host); local driver
|
||||||
- **Inbound webhooks** — project-scoped ingest endpoints that turn
|
returns a clear error — a presigned URL needs a real object API behind it.
|
||||||
external events into domain events/jobs.
|
- **HA mode** — ✅ shipped: PostgreSQL advisory-lock leadership for the
|
||||||
- **HA mode** — leader-elected scheduler + distributed locks so two
|
scheduler and alert evaluator; failover verified with two replicas.
|
||||||
backend replicas don't double-fire jobs; Postgres advisory locks are
|
- **Email surface** — ✅ shipped: templated sends + `core.email_log` +
|
||||||
enough at this scale.
|
dashboard card; more templates land as new flows need them.
|
||||||
- **Email surface** — transactional templates + send log in the
|
|
||||||
dashboard, backed by the auth service's existing SMTP/Resend wiring.
|
## 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`.
|
||||||
|
|
||||||
|
## App logins (shipped, unreleased)
|
||||||
|
|
||||||
|
Username and password for a website or for an app the user is building.
|
||||||
|
Stored in both the local vault and the project vault. The app id is a
|
||||||
|
stable name (`my-app`, the project slug), not a host or a port, because
|
||||||
|
`localhost:3000` can serve something else later. `origin` is only the
|
||||||
|
current URL.
|
||||||
|
|
||||||
|
Agents read one named secret or login when they need it
|
||||||
|
(`primora_vault_get`, `primora_secrets_get`, `primora_logins_get`).
|
||||||
|
That is the point of the vault: encrypted at rest, fetched at use, not
|
||||||
|
pasted into chat and not written to `.env`. Lists stay metadata.
|
||||||
|
Project reveals are audit-logged. A `write` API key can reveal; a `read`
|
||||||
|
key cannot.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { requestId } from "hono/request-id";
|
|||||||
import { HTTPException } from "hono/http-exception";
|
import { HTTPException } from "hono/http-exception";
|
||||||
import { createClient } from "redis";
|
import { createClient } from "redis";
|
||||||
|
|
||||||
import { auth, authPool, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js";
|
import { auth, promoteAdminEmails, runAuthMigrations, verifyUsersWithoutMailTransport } from "./lib/auth.js";
|
||||||
|
import { authPool } from "./lib/db.js";
|
||||||
import { env } from "./lib/env.js";
|
import { env } from "./lib/env.js";
|
||||||
|
|
||||||
const redisClient = createClient({
|
const redisClient = createClient({
|
||||||
@@ -19,6 +20,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await retry("auth_migrations", runAuthMigrations);
|
await retry("auth_migrations", runAuthMigrations);
|
||||||
|
await verifyUsersWithoutMailTransport();
|
||||||
await promoteAdminEmails();
|
await promoteAdminEmails();
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|||||||
+119
-48
@@ -1,56 +1,74 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
|
import { APIError } from "better-auth/api";
|
||||||
import { admin, jwt } from "better-auth/plugins";
|
import { admin, jwt } from "better-auth/plugins";
|
||||||
import { getMigrations } from "better-auth/db/migration";
|
import { getMigrations } from "better-auth/db/migration";
|
||||||
import { Pool } from "pg";
|
|
||||||
|
|
||||||
import { sendTransactionalEmail } from "./mail.js";
|
import { authPool } from "./db.js";
|
||||||
|
import { hasMailTransport, sendTransactionalEmail } from "./mail.js";
|
||||||
|
import { getBoolSetting } from "./settings.js";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
|
|
||||||
export const authPool = new Pool({
|
// OAuth providers exist only on the managed tier (PRIMORA_MANAGED=true).
|
||||||
connectionString: env.DATABASE_URL,
|
// Self-hosted instances are email/password only — the env keys are ignored
|
||||||
});
|
// unless the managed flag is set.
|
||||||
|
const socialProviders = env.PRIMORA_MANAGED
|
||||||
const socialProviders = {
|
? {
|
||||||
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
||||||
? {
|
? {
|
||||||
github: {
|
github: {
|
||||||
clientId: env.GITHUB_CLIENT_ID,
|
clientId: env.GITHUB_CLIENT_ID,
|
||||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
||||||
? {
|
? {
|
||||||
google: {
|
google: {
|
||||||
clientId: env.GOOGLE_CLIENT_ID,
|
clientId: env.GOOGLE_CLIENT_ID,
|
||||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
|
...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
|
||||||
? {
|
? {
|
||||||
discord: {
|
discord: {
|
||||||
clientId: env.DISCORD_CLIENT_ID,
|
clientId: env.DISCORD_CLIENT_ID,
|
||||||
clientSecret: env.DISCORD_CLIENT_SECRET,
|
clientSecret: env.DISCORD_CLIENT_SECRET,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
|
...(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET
|
||||||
? {
|
? {
|
||||||
microsoft: {
|
microsoft: {
|
||||||
clientId: env.MICROSOFT_CLIENT_ID,
|
clientId: env.MICROSOFT_CLIENT_ID,
|
||||||
clientSecret: env.MICROSOFT_CLIENT_SECRET,
|
clientSecret: env.MICROSOFT_CLIENT_SECRET,
|
||||||
tenantId: env.MICROSOFT_TENANT_ID,
|
tenantId: env.MICROSOFT_TENANT_ID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
appName: "Primora",
|
appName: "Primora",
|
||||||
secret: env.BETTER_AUTH_SECRET,
|
secret: env.BETTER_AUTH_SECRET,
|
||||||
baseURL: env.BETTER_AUTH_URL,
|
baseURL: env.BETTER_AUTH_URL,
|
||||||
trustedOrigins: [env.VITE_APP_URL, env.AUTH_BASE_URL],
|
// Self-host: the browser reaches /auth through the same nginx origin as the
|
||||||
|
// app, so whatever host actually served this request is trusted — static
|
||||||
|
// localhost defaults alone 403 every sign-up on LAN IPs or custom ports.
|
||||||
|
// An attacker's cross-site Origin still won't match the server host.
|
||||||
|
trustedOrigins: (request) => {
|
||||||
|
const origins = [env.VITE_APP_URL, env.AUTH_BASE_URL];
|
||||||
|
const host = request?.headers.get("x-forwarded-host") ?? request?.headers.get("host");
|
||||||
|
if (host) {
|
||||||
|
const proto = request?.headers.get("x-forwarded-proto") ?? "http";
|
||||||
|
origins.push(`${proto}://${host}`);
|
||||||
|
// TLS often terminates at an external proxy — nginx then reports http.
|
||||||
|
// Both schemes on the serving host are legitimately this instance.
|
||||||
|
origins.push(proto === "https" ? `http://${host}` : `https://${host}`);
|
||||||
|
}
|
||||||
|
return origins;
|
||||||
|
},
|
||||||
database: authPool,
|
database: authPool,
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -63,16 +81,58 @@ 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) {
|
||||||
|
const data = { ...user };
|
||||||
|
// Without a mail transport the verification mail can never arrive —
|
||||||
|
// mark every created account verified so nothing downstream nags
|
||||||
|
// "unverified" on users nobody can verify.
|
||||||
|
if (!(await hasMailTransport())) {
|
||||||
|
data.emailVerified = true;
|
||||||
|
}
|
||||||
|
if (ctx?.path === "/sign-up/email") {
|
||||||
|
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: { ...data, 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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data.emailVerified === user.emailVerified ? undefined : { data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
emailVerification: {
|
emailVerification: {
|
||||||
sendOnSignUp: true,
|
sendOnSignUp: true,
|
||||||
autoSignInAfterVerification: true,
|
autoSignInAfterVerification: true,
|
||||||
async sendVerificationEmail({ user, url }) {
|
async sendVerificationEmail({ user, url }) {
|
||||||
await sendTransactionalEmail({
|
// No transport at all → the mail can never arrive; the user was already
|
||||||
to: user.email,
|
// marked verified at creation.
|
||||||
subject: "Verify your Primora email",
|
if (!(await hasMailTransport())) return;
|
||||||
text: `Verify your Primora email: ${url}`,
|
// Verification is optional — a misconfigured transport must not fail
|
||||||
html: `<p>Verify your Primora email.</p><p><a href="${url}">Verify email</a></p>`,
|
// 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
|
// Enabled unconditionally — better-auth only turns this on when NODE_ENV is
|
||||||
@@ -110,6 +170,17 @@ export async function runAuthMigrations() {
|
|||||||
await migrations.runMigrations();
|
await migrations.runMigrations();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** No transport → existing unverified users can never resolve that state.
|
||||||
|
* Best-effort: a startup failure here must never take the service down. */
|
||||||
|
export async function verifyUsersWithoutMailTransport() {
|
||||||
|
try {
|
||||||
|
if (await hasMailTransport()) return;
|
||||||
|
await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(JSON.stringify({ level: "warn", msg: "verify_without_transport_failed", error }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Bootstrap admins by email — run after migrations so the role column exists. */
|
/** Bootstrap admins by email — run after migrations so the role column exists. */
|
||||||
export async function promoteAdminEmails() {
|
export async function promoteAdminEmails() {
|
||||||
const emails = (env.AUTH_ADMIN_EMAILS ?? "")
|
const emails = (env.AUTH_ADMIN_EMAILS ?? "")
|
||||||
|
|||||||
@@ -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_PORT: z.string().default("1025"),
|
||||||
SMTP_USER: z.string().optional(),
|
SMTP_USER: z.string().optional(),
|
||||||
SMTP_PASSWORD: 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_ID: z.string().optional(),
|
||||||
GITHUB_CLIENT_SECRET: z.string().optional(),
|
GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||||
GOOGLE_CLIENT_ID: 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),
|
JWT_TTL_SECONDS: Number(parsed.data.JWT_TTL_SECONDS),
|
||||||
SMTP_PORT: Number(parsed.data.SMTP_PORT),
|
SMTP_PORT: Number(parsed.data.SMTP_PORT),
|
||||||
AUTH_BASE_URL: parsed.data.AUTH_BASE_URL ?? parsed.data.BETTER_AUTH_URL,
|
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",
|
||||||
};
|
};
|
||||||
|
|
||||||
+54
-22
@@ -2,23 +2,48 @@ import nodemailer from "nodemailer";
|
|||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
|
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
|
import { getStringSetting } from "./settings.js";
|
||||||
|
|
||||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : 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;
|
||||||
|
}
|
||||||
|
|
||||||
const transporter =
|
// True when a mail transport exists (Resend key or SMTP host). Verification
|
||||||
!resend && env.SMTP_HOST
|
// and reset mails only make sense when mail can actually leave the box.
|
||||||
? nodemailer.createTransport({
|
// Mail is optional — a failed settings read must degrade to "no transport",
|
||||||
host: env.SMTP_HOST,
|
// never break sign-up.
|
||||||
port: env.SMTP_PORT,
|
export async function hasMailTransport(): Promise<boolean> {
|
||||||
secure: false,
|
try {
|
||||||
auth: env.SMTP_USER
|
const cfg = await resolveMailConfig();
|
||||||
? {
|
if ("resendApiKey" in cfg && cfg.resendApiKey) return true;
|
||||||
user: env.SMTP_USER,
|
return "smtp" in cfg && Boolean(cfg.smtp);
|
||||||
pass: env.SMTP_PASSWORD,
|
} catch (error) {
|
||||||
}
|
console.warn(JSON.stringify({ level: "warn", msg: "mail_transport_probe_failed", error }));
|
||||||
: undefined,
|
return false;
|
||||||
})
|
}
|
||||||
: null;
|
}
|
||||||
|
|
||||||
export async function sendTransactionalEmail(input: {
|
export async function sendTransactionalEmail(input: {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -26,9 +51,12 @@ export async function sendTransactionalEmail(input: {
|
|||||||
text: string;
|
text: string;
|
||||||
html: 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({
|
await resend.emails.send({
|
||||||
from: env.MAIL_FROM,
|
from: cfg.from,
|
||||||
to: [input.to],
|
to: [input.to],
|
||||||
subject: input.subject,
|
subject: input.subject,
|
||||||
text: input.text,
|
text: input.text,
|
||||||
@@ -37,16 +65,20 @@ export async function sendTransactionalEmail(input: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!transporter) {
|
if (!("smtp" in cfg) || !cfg.smtp) {
|
||||||
throw new Error("No mail transport configured for auth service.");
|
// 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({
|
await nodemailer.createTransport(cfg.smtp).sendMail({
|
||||||
from: env.MAIL_FROM,
|
from: cfg.from,
|
||||||
to: input.to,
|
to: input.to,
|
||||||
subject: input.subject,
|
subject: input.subject,
|
||||||
text: input.text,
|
text: input.text,
|
||||||
html: input.html,
|
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 \
|
&& chmod +x /usr/local/bin/dbx-mcp \
|
||||||
&& rm /tmp/dbx-mcp.tar.gz
|
&& rm /tmp/dbx-mcp.tar.gz
|
||||||
|
|
||||||
|
# Functions runtime — bun static binary from the official image (multi-arch).
|
||||||
|
COPY --from=oven/bun:1.3.14 /usr/local/bin/bun /usr/local/bin/bun
|
||||||
|
|
||||||
COPY --from=builder /primora-backend /usr/local/bin/primora-backend
|
COPY --from=builder /primora-backend /usr/local/bin/primora-backend
|
||||||
COPY --from=builder /app/db ./db
|
COPY --from=builder /app/db ./db
|
||||||
COPY --from=builder /app/openapi ./openapi
|
COPY --from=builder /app/openapi ./openapi
|
||||||
|
|||||||
@@ -0,0 +1,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,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- App logins: username + password bound to a stable app id, not a host:port.
|
||||||
|
-- The password is AES-256-GCM ciphertext. List returns metadata (including
|
||||||
|
-- the username and origin); reveal decrypts and is audit-logged.
|
||||||
|
CREATE TABLE core.project_logins (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
app TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
ciphertext BYTEA NOT NULL,
|
||||||
|
origin 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, app)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS core.project_logins;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- name: CreateAlertRule :one
|
||||||
|
INSERT INTO core.alert_rules (project_id, name, kind, config)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListAlertRules :many
|
||||||
|
SELECT * FROM core.alert_rules
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
-- name: ListEnabledAlertRules :many
|
||||||
|
SELECT * FROM core.alert_rules
|
||||||
|
WHERE enabled;
|
||||||
|
|
||||||
|
-- name: GetAlertRule :one
|
||||||
|
SELECT * FROM core.alert_rules
|
||||||
|
WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- name: UpdateAlertRule :one
|
||||||
|
UPDATE core.alert_rules
|
||||||
|
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: SetAlertRuleState :exec
|
||||||
|
UPDATE core.alert_rules
|
||||||
|
SET state = $3, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- name: DeleteAlertRule :exec
|
||||||
|
DELETE FROM core.alert_rules
|
||||||
|
WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- Per-component most recent heartbeat — heartbeat_silence detection.
|
||||||
|
-- name: LastHeartbeats :many
|
||||||
|
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
|
||||||
|
FROM core.components co
|
||||||
|
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
|
||||||
|
WHERE co.project_id = $1
|
||||||
|
GROUP BY co.name;
|
||||||
|
|
||||||
|
-- Per-component error count inside a sliding window — error_spike detection.
|
||||||
|
-- name: RecentErrorCounts :many
|
||||||
|
SELECT co.name AS component_name, count(*) AS errors
|
||||||
|
FROM core.events e
|
||||||
|
JOIN core.components co ON co.id = e.component_id
|
||||||
|
WHERE e.project_id = $1 AND e.type = 'error'
|
||||||
|
AND e.ts > now() - make_interval(secs => $2::bigint)
|
||||||
|
GROUP BY co.name;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- name: InsertEmailLog :one
|
||||||
|
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListEmailLog :many
|
||||||
|
SELECT * FROM core.email_log
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- name: CreateFunction :one
|
||||||
|
INSERT INTO core.functions (project_id, name, code, runtime, event_pattern)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetFunction :one
|
||||||
|
SELECT * FROM core.functions WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetFunctionByName :one
|
||||||
|
SELECT * FROM core.functions WHERE project_id = $1 AND name = $2;
|
||||||
|
|
||||||
|
-- name: ListFunctions :many
|
||||||
|
SELECT * FROM core.functions
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
-- name: UpdateFunction :one
|
||||||
|
UPDATE core.functions
|
||||||
|
SET code = COALESCE(sqlc.narg('code'), code),
|
||||||
|
enabled = COALESCE(sqlc.narg('enabled'), enabled),
|
||||||
|
event_pattern = COALESCE(sqlc.narg('event_pattern'), event_pattern),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteFunction :exec
|
||||||
|
DELETE FROM core.functions WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- name: InsertFunctionRun :one
|
||||||
|
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListFunctionRuns :many
|
||||||
|
SELECT * FROM core.function_runs
|
||||||
|
WHERE function_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2;
|
||||||
|
|
||||||
|
-- name: ListFunctionsForEvent :many
|
||||||
|
SELECT * FROM core.functions
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND enabled = TRUE
|
||||||
|
AND event_pattern <> ''
|
||||||
|
AND ($2 LIKE REPLACE(event_pattern, '*', '%') OR event_pattern = '*');
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- name: CreateInboundHook :one
|
||||||
|
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, function_id, secret, enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListInboundHooks :many
|
||||||
|
SELECT * FROM core.inbound_hooks
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
-- name: GetInboundHookByToken :one
|
||||||
|
SELECT * FROM core.inbound_hooks
|
||||||
|
WHERE token = $1;
|
||||||
|
|
||||||
|
-- name: GetInboundHook :one
|
||||||
|
SELECT * FROM core.inbound_hooks
|
||||||
|
WHERE id = $1 AND project_id = $2;
|
||||||
|
|
||||||
|
-- name: TouchInboundHook :exec
|
||||||
|
UPDATE core.inbound_hooks
|
||||||
|
SET last_received_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: DeleteInboundHook :exec
|
||||||
|
DELETE FROM core.inbound_hooks
|
||||||
|
WHERE id = $1 AND project_id = $2;
|
||||||
@@ -17,8 +17,9 @@ INSERT INTO core.scheduled_jobs (
|
|||||||
payload,
|
payload,
|
||||||
enabled,
|
enabled,
|
||||||
next_run_at,
|
next_run_at,
|
||||||
created_by_user_id
|
created_by_user_id,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
function_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpdateScheduledJob :one
|
-- name: UpdateScheduledJob :one
|
||||||
@@ -30,6 +31,7 @@ SET name = $3,
|
|||||||
next_run_at = $7,
|
next_run_at = $7,
|
||||||
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
|
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
|
||||||
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
|
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
|
||||||
|
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND project_id = $2
|
AND project_id = $2
|
||||||
|
|||||||
@@ -147,3 +147,7 @@ WHERE p.organization_id = $1;
|
|||||||
DELETE FROM core.organizations
|
DELETE FROM core.organizations
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetOrganizationByID :one
|
||||||
|
SELECT * FROM core.organizations
|
||||||
|
WHERE id = $1;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- name: ListProjectLogins :many
|
||||||
|
SELECT id, project_id, app, username, origin, notes, created_at, updated_at
|
||||||
|
FROM core.project_logins
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY app ASC;
|
||||||
|
|
||||||
|
-- name: GetProjectLogin :one
|
||||||
|
SELECT * FROM core.project_logins
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND app = $2;
|
||||||
|
|
||||||
|
-- name: UpsertProjectLogin :one
|
||||||
|
INSERT INTO core.project_logins (
|
||||||
|
project_id,
|
||||||
|
app,
|
||||||
|
username,
|
||||||
|
ciphertext,
|
||||||
|
origin,
|
||||||
|
notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (project_id, app) DO UPDATE SET
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
ciphertext = EXCLUDED.ciphertext,
|
||||||
|
origin = EXCLUDED.origin,
|
||||||
|
notes = EXCLUDED.notes,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeleteProjectLogin :one
|
||||||
|
DELETE FROM core.project_logins
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND app = $2
|
||||||
|
RETURNING id;
|
||||||
@@ -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
|
SELECT * FROM core.webhooks
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
AND enabled
|
AND enabled
|
||||||
AND (cardinality(events) = 0 OR $2::text = ANY(events));
|
AND (cardinality(events) = 0 OR EXISTS (
|
||||||
|
SELECT 1 FROM unnest(events) AS e
|
||||||
|
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
|
||||||
|
));
|
||||||
|
|
||||||
-- name: CreateWebhook :one
|
-- name: CreateWebhook :one
|
||||||
INSERT INTO core.webhooks (
|
INSERT INTO core.webhooks (
|
||||||
|
|||||||
@@ -81,9 +81,31 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := storage.NewLocalStore(cfg.StorageRoot)
|
var store storage.Store
|
||||||
if err != nil {
|
if cfg.StorageDriver == "s3" {
|
||||||
return nil, err
|
s3Store, err := storage.NewS3Store(storage.S3Config{
|
||||||
|
Endpoint: cfg.S3Endpoint,
|
||||||
|
Region: cfg.S3Region,
|
||||||
|
Bucket: cfg.S3Bucket,
|
||||||
|
AccessKeyID: cfg.S3AccessKeyID,
|
||||||
|
SecretAccessKey: cfg.S3SecretAccessKey,
|
||||||
|
PublicEndpoint: cfg.S3PublicEndpoint,
|
||||||
|
Prefix: cfg.S3Prefix,
|
||||||
|
PathStyle: cfg.S3PathStyle,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("s3 storage: %w", err)
|
||||||
|
}
|
||||||
|
if err := s3Store.EnsureBucket(context.Background()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
store = s3Store
|
||||||
|
} else {
|
||||||
|
localStore, err := storage.NewLocalStore(cfg.StorageRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
store = localStore
|
||||||
}
|
}
|
||||||
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
|
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
|
||||||
repo := repositories.NewCoreRepository(dbPool)
|
repo := repositories.NewCoreRepository(dbPool)
|
||||||
@@ -91,7 +113,14 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("encryption key: %w", err)
|
return nil, fmt.Errorf("encryption key: %w", err)
|
||||||
}
|
}
|
||||||
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger)
|
var fnRunner services.FunctionRunner
|
||||||
|
if cfg.FunctionsDriver == "docker" {
|
||||||
|
fnRunner = services.NewDockerRunner(cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
|
||||||
|
} else {
|
||||||
|
fnRunner = services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
|
||||||
|
}
|
||||||
|
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, fnRunner)
|
||||||
|
settings := services.NewSettingsService(repo, encryptor)
|
||||||
|
|
||||||
if cfg.Env == "production" {
|
if cfg.Env == "production" {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
@@ -126,10 +155,12 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
|||||||
APIKeyPerMinute: cfg.APIKeyRateLimitPerMin,
|
APIKeyPerMinute: cfg.APIKeyRateLimitPerMin,
|
||||||
UserPerMinute: cfg.UserRateLimitPerMin,
|
UserPerMinute: cfg.UserRateLimitPerMin,
|
||||||
},
|
},
|
||||||
|
RateLimitResolver: settings.RateLimitPerMinute,
|
||||||
}.ResolveActor())
|
}.ResolveActor())
|
||||||
|
|
||||||
handler := &handlers.HTTPHandler{
|
handler := &handlers.HTTPHandler{
|
||||||
Platform: platform,
|
Platform: platform,
|
||||||
|
Settings: settings,
|
||||||
Validate: validator.New(),
|
Validate: validator.New(),
|
||||||
Metrics: metrics,
|
Metrics: metrics,
|
||||||
Readiness: func(c *gin.Context) map[string]any {
|
Readiness: func(c *gin.Context) map[string]any {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -35,6 +36,26 @@ type Config struct {
|
|||||||
SMTPSecure bool
|
SMTPSecure bool
|
||||||
|
|
||||||
EncryptionKey string
|
EncryptionKey string
|
||||||
|
|
||||||
|
// Object storage: "local" (BACKEND_STORAGE_ROOT) or "s3".
|
||||||
|
StorageDriver string
|
||||||
|
S3Endpoint string
|
||||||
|
S3Region string
|
||||||
|
S3Bucket string
|
||||||
|
S3AccessKeyID string
|
||||||
|
S3SecretAccessKey string
|
||||||
|
S3PublicEndpoint string // presigned-URL host when it differs from Endpoint
|
||||||
|
S3Prefix string
|
||||||
|
S3PathStyle bool
|
||||||
|
|
||||||
|
// Functions runtime: driver "exec" (runtime binary on the backend host)
|
||||||
|
// or "docker" (ephemeral locked-down container), binary name/path for
|
||||||
|
// exec ("auto" resolves per-function runtime from PATH), timeout and
|
||||||
|
// output cap.
|
||||||
|
FunctionsDriver string
|
||||||
|
FunctionsRuntime string
|
||||||
|
FunctionsTimeout time.Duration
|
||||||
|
FunctionsMaxBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// devEncryptionKey is the built-in credential key for local development only.
|
// devEncryptionKey is the built-in credential key for local development only.
|
||||||
@@ -63,6 +84,17 @@ func Load() (Config, error) {
|
|||||||
SMTPUser: os.Getenv("SMTP_USER"),
|
SMTPUser: os.Getenv("SMTP_USER"),
|
||||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||||
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
|
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
|
||||||
|
StorageDriver: getenv("BACKEND_STORAGE_DRIVER", "local"),
|
||||||
|
S3Endpoint: os.Getenv("S3_ENDPOINT"),
|
||||||
|
S3Region: getenv("S3_REGION", "us-east-1"),
|
||||||
|
S3Bucket: os.Getenv("S3_BUCKET"),
|
||||||
|
S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"),
|
||||||
|
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
||||||
|
S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"),
|
||||||
|
S3Prefix: os.Getenv("S3_PREFIX"),
|
||||||
|
FunctionsDriver: getenv("FUNCTIONS_DRIVER", "exec"),
|
||||||
|
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
|
||||||
|
FunctionsMaxBytes: 65536,
|
||||||
}
|
}
|
||||||
|
|
||||||
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
||||||
@@ -95,6 +127,24 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.SMTPSecure = smtpSecure
|
cfg.SMTPSecure = smtpSecure
|
||||||
|
|
||||||
|
s3PathStyle, err := strconv.ParseBool(getenv("S3_PATH_STYLE", "true"))
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("parse S3_PATH_STYLE: %w", err)
|
||||||
|
}
|
||||||
|
cfg.S3PathStyle = s3PathStyle
|
||||||
|
|
||||||
|
fnTimeout, err := strconv.Atoi(getenv("FUNCTIONS_TIMEOUT_SECONDS", "30"))
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("parse FUNCTIONS_TIMEOUT_SECONDS: %w", err)
|
||||||
|
}
|
||||||
|
cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second
|
||||||
|
|
||||||
|
switch cfg.FunctionsDriver {
|
||||||
|
case "exec", "docker":
|
||||||
|
default:
|
||||||
|
return Config{}, fmt.Errorf("FUNCTIONS_DRIVER %q must be exec or docker", cfg.FunctionsDriver)
|
||||||
|
}
|
||||||
|
|
||||||
var missing []string
|
var missing []string
|
||||||
if cfg.DatabaseURL == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
missing = append(missing, "DATABASE_URL")
|
missing = append(missing, "DATABASE_URL")
|
||||||
@@ -102,8 +152,23 @@ func Load() (Config, error) {
|
|||||||
if cfg.JWTSecret == "" {
|
if cfg.JWTSecret == "" {
|
||||||
missing = append(missing, "JWT_SECRET")
|
missing = append(missing, "JWT_SECRET")
|
||||||
}
|
}
|
||||||
if cfg.StorageRoot == "" {
|
switch cfg.StorageDriver {
|
||||||
missing = append(missing, "BACKEND_STORAGE_ROOT")
|
case "local":
|
||||||
|
if cfg.StorageRoot == "" {
|
||||||
|
missing = append(missing, "BACKEND_STORAGE_ROOT")
|
||||||
|
}
|
||||||
|
case "s3":
|
||||||
|
if cfg.S3Bucket == "" {
|
||||||
|
missing = append(missing, "S3_BUCKET")
|
||||||
|
}
|
||||||
|
if cfg.S3AccessKeyID == "" {
|
||||||
|
missing = append(missing, "S3_ACCESS_KEY_ID")
|
||||||
|
}
|
||||||
|
if cfg.S3SecretAccessKey == "" {
|
||||||
|
missing = append(missing, "S3_SECRET_ACCESS_KEY")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return Config{}, fmt.Errorf("unknown BACKEND_STORAGE_DRIVER %q (want local or s3)", cfg.StorageDriver)
|
||||||
}
|
}
|
||||||
if cfg.AuthInternalBaseURL == "" {
|
if cfg.AuthInternalBaseURL == "" {
|
||||||
missing = append(missing, "AUTH_INTERNAL_BASE_URL")
|
missing = append(missing, "AUTH_INTERNAL_BASE_URL")
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: alert_rules.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createAlertRule = `-- name: CreateAlertRule :one
|
||||||
|
INSERT INTO core.alert_rules (project_id, name, kind, config)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateAlertRuleParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Config []byte `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createAlertRule,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Kind,
|
||||||
|
arg.Config,
|
||||||
|
)
|
||||||
|
var i CoreAlertRule
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAlertRule = `-- name: DeleteAlertRule :exec
|
||||||
|
DELETE FROM core.alert_rules
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteAlertRuleParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteAlertRule, arg.ID, arg.ProjectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAlertRule = `-- name: GetAlertRule :one
|
||||||
|
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAlertRuleParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getAlertRule, arg.ID, arg.ProjectID)
|
||||||
|
var i CoreAlertRule
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastHeartbeats = `-- name: LastHeartbeats :many
|
||||||
|
SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat
|
||||||
|
FROM core.components co
|
||||||
|
JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat'
|
||||||
|
WHERE co.project_id = $1
|
||||||
|
GROUP BY co.name
|
||||||
|
`
|
||||||
|
|
||||||
|
type LastHeartbeatsRow struct {
|
||||||
|
ComponentName string `json:"component_name"`
|
||||||
|
LastBeat pgtype.Timestamptz `json:"last_beat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-component most recent heartbeat — heartbeat_silence detection.
|
||||||
|
func (q *Queries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, lastHeartbeats, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []LastHeartbeatsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i LastHeartbeatsRow
|
||||||
|
if err := rows.Scan(&i.ComponentName, &i.LastBeat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAlertRules = `-- name: ListAlertRules :many
|
||||||
|
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAlertRules, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreAlertRule
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreAlertRule
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEnabledAlertRules = `-- name: ListEnabledAlertRules :many
|
||||||
|
SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules
|
||||||
|
WHERE enabled
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listEnabledAlertRules)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreAlertRule
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreAlertRule
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentErrorCounts = `-- name: RecentErrorCounts :many
|
||||||
|
SELECT co.name AS component_name, count(*) AS errors
|
||||||
|
FROM core.events e
|
||||||
|
JOIN core.components co ON co.id = e.component_id
|
||||||
|
WHERE e.project_id = $1 AND e.type = 'error'
|
||||||
|
AND e.ts > now() - make_interval(secs => $2::bigint)
|
||||||
|
GROUP BY co.name
|
||||||
|
`
|
||||||
|
|
||||||
|
type RecentErrorCountsParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Column2 int64 `json:"column_2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecentErrorCountsRow struct {
|
||||||
|
ComponentName string `json:"component_name"`
|
||||||
|
Errors int64 `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-component error count inside a sliding window — error_spike detection.
|
||||||
|
func (q *Queries) RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, recentErrorCounts, arg.ProjectID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecentErrorCountsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecentErrorCountsRow
|
||||||
|
if err := rows.Scan(&i.ComponentName, &i.Errors); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAlertRuleState = `-- name: SetAlertRuleState :exec
|
||||||
|
UPDATE core.alert_rules
|
||||||
|
SET state = $3, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetAlertRuleStateParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
State []byte `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, setAlertRuleState, arg.ID, arg.ProjectID, arg.State)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAlertRule = `-- name: UpdateAlertRule :one
|
||||||
|
UPDATE core.alert_rules
|
||||||
|
SET name = $3, config = $4, enabled = $5, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateAlertRuleParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Config []byte `json:"config"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateAlertRule,
|
||||||
|
arg.ID,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Config,
|
||||||
|
arg.Enabled,
|
||||||
|
)
|
||||||
|
var i CoreAlertRule
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Config,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.State,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: email_log.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const insertEmailLog = `-- name: InsertEmailLog :one
|
||||||
|
INSERT INTO core.email_log (project_id, template, to_email, subject, status, error)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, project_id, template, to_email, subject, status, error, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertEmailLogParams struct {
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
ToEmail string `json:"to_email"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertEmailLog,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Template,
|
||||||
|
arg.ToEmail,
|
||||||
|
arg.Subject,
|
||||||
|
arg.Status,
|
||||||
|
arg.Error,
|
||||||
|
)
|
||||||
|
var i CoreEmailLog
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Template,
|
||||||
|
&i.ToEmail,
|
||||||
|
&i.Subject,
|
||||||
|
&i.Status,
|
||||||
|
&i.Error,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEmailLog = `-- name: ListEmailLog :many
|
||||||
|
SELECT id, project_id, template, to_email, subject, status, error, created_at FROM core.email_log
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListEmailLogParams struct {
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listEmailLog, arg.ProjectID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreEmailLog
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreEmailLog
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Template,
|
||||||
|
&i.ToEmail,
|
||||||
|
&i.Subject,
|
||||||
|
&i.Status,
|
||||||
|
&i.Error,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: functions.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createFunction = `-- name: CreateFunction :one
|
||||||
|
INSERT INTO core.functions (project_id, name, code, runtime, event_pattern)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateFunctionParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
EventPattern string `json:"event_pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createFunction,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Code,
|
||||||
|
arg.Runtime,
|
||||||
|
arg.EventPattern,
|
||||||
|
)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFunction = `-- name: DeleteFunction :exec
|
||||||
|
DELETE FROM core.functions WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteFunctionParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteFunction, arg.ID, arg.ProjectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFunction = `-- name: GetFunction :one
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFunction, id)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFunctionByName = `-- name: GetFunctionByName :one
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions WHERE project_id = $1 AND name = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetFunctionByNameParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getFunctionByName, arg.ProjectID, arg.Name)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertFunctionRun = `-- name: InsertFunctionRun :one
|
||||||
|
INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertFunctionRunParams struct {
|
||||||
|
FunctionID uuid.UUID `json:"function_id"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode *int32 `json:"exit_code"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertFunctionRun,
|
||||||
|
arg.FunctionID,
|
||||||
|
arg.Trigger,
|
||||||
|
arg.Status,
|
||||||
|
arg.ExitCode,
|
||||||
|
arg.Stdout,
|
||||||
|
arg.Stderr,
|
||||||
|
arg.DurationMs,
|
||||||
|
)
|
||||||
|
var i CoreFunctionRun
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FunctionID,
|
||||||
|
&i.Trigger,
|
||||||
|
&i.Status,
|
||||||
|
&i.ExitCode,
|
||||||
|
&i.Stdout,
|
||||||
|
&i.Stderr,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFunctionRuns = `-- name: ListFunctionRuns :many
|
||||||
|
SELECT id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at FROM core.function_runs
|
||||||
|
WHERE function_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListFunctionRunsParams struct {
|
||||||
|
FunctionID uuid.UUID `json:"function_id"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listFunctionRuns, arg.FunctionID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreFunctionRun
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreFunctionRun
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.FunctionID,
|
||||||
|
&i.Trigger,
|
||||||
|
&i.Status,
|
||||||
|
&i.ExitCode,
|
||||||
|
&i.Stdout,
|
||||||
|
&i.Stderr,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFunctions = `-- name: ListFunctions :many
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listFunctions, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreFunction
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreFunction
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFunctionsForEvent = `-- name: ListFunctionsForEvent :many
|
||||||
|
SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern FROM core.functions
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND enabled = TRUE
|
||||||
|
AND event_pattern <> ''
|
||||||
|
AND ($2 LIKE REPLACE(event_pattern, '*', '%') OR event_pattern = '*')
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListFunctionsForEventParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
EventPattern string `json:"event_pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listFunctionsForEvent, arg.ProjectID, arg.EventPattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreFunction
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreFunction
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFunction = `-- name: UpdateFunction :one
|
||||||
|
UPDATE core.functions
|
||||||
|
SET code = COALESCE($3, code),
|
||||||
|
enabled = COALESCE($4, enabled),
|
||||||
|
event_pattern = COALESCE($5, event_pattern),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at, event_pattern
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateFunctionParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Code *string `json:"code"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
EventPattern *string `json:"event_pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateFunction,
|
||||||
|
arg.ID,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Code,
|
||||||
|
arg.Enabled,
|
||||||
|
arg.EventPattern,
|
||||||
|
)
|
||||||
|
var i CoreFunction
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Code,
|
||||||
|
&i.Runtime,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EventPattern,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: inbound_hooks.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createInboundHook = `-- name: CreateInboundHook :one
|
||||||
|
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, function_id, secret, enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateInboundHookParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
JobID pgtype.UUID `json:"job_id"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
|
Secret []byte `json:"secret"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createInboundHook,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Name,
|
||||||
|
arg.Token,
|
||||||
|
arg.Mode,
|
||||||
|
arg.JobID,
|
||||||
|
arg.FunctionID,
|
||||||
|
arg.Secret,
|
||||||
|
arg.Enabled,
|
||||||
|
)
|
||||||
|
var i CoreInboundHook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Token,
|
||||||
|
&i.Mode,
|
||||||
|
&i.JobID,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastReceivedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteInboundHook = `-- name: DeleteInboundHook :exec
|
||||||
|
DELETE FROM core.inbound_hooks
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteInboundHookParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteInboundHook, arg.ID, arg.ProjectID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInboundHook = `-- name: GetInboundHook :one
|
||||||
|
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||||
|
WHERE id = $1 AND project_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetInboundHookParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getInboundHook, arg.ID, arg.ProjectID)
|
||||||
|
var i CoreInboundHook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Token,
|
||||||
|
&i.Mode,
|
||||||
|
&i.JobID,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastReceivedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInboundHookByToken = `-- name: GetInboundHookByToken :one
|
||||||
|
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||||
|
WHERE token = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getInboundHookByToken, token)
|
||||||
|
var i CoreInboundHook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Token,
|
||||||
|
&i.Mode,
|
||||||
|
&i.JobID,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastReceivedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listInboundHooks = `-- name: ListInboundHooks :many
|
||||||
|
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at, function_id FROM core.inbound_hooks
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listInboundHooks, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreInboundHook
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreInboundHook
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Token,
|
||||||
|
&i.Mode,
|
||||||
|
&i.JobID,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.LastReceivedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchInboundHook = `-- name: TouchInboundHook :exec
|
||||||
|
UPDATE core.inbound_hooks
|
||||||
|
SET last_received_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) TouchInboundHook(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, touchInboundHook, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -22,9 +22,10 @@ INSERT INTO core.scheduled_jobs (
|
|||||||
payload,
|
payload,
|
||||||
enabled,
|
enabled,
|
||||||
next_run_at,
|
next_run_at,
|
||||||
created_by_user_id
|
created_by_user_id,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
function_id
|
||||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateScheduledJobParams struct {
|
type CreateScheduledJobParams struct {
|
||||||
@@ -37,6 +38,7 @@ type CreateScheduledJobParams struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
|
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
|
||||||
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) {
|
func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) {
|
||||||
@@ -50,6 +52,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob
|
|||||||
arg.Enabled,
|
arg.Enabled,
|
||||||
arg.NextRunAt,
|
arg.NextRunAt,
|
||||||
arg.CreatedByUserID,
|
arg.CreatedByUserID,
|
||||||
|
arg.FunctionID,
|
||||||
)
|
)
|
||||||
var i CoreScheduledJob
|
var i CoreScheduledJob
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -67,6 +70,7 @@ func (q *Queries) CreateScheduledJob(ctx context.Context, arg CreateScheduledJob
|
|||||||
&i.CreatedByUserID,
|
&i.CreatedByUserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -133,7 +137,7 @@ func (q *Queries) FinishScheduledJobRun(ctx context.Context, arg FinishScheduled
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getScheduledJobByID = `-- name: GetScheduledJobByID :one
|
const getScheduledJobByID = `-- name: GetScheduledJobByID :one
|
||||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
|
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -155,6 +159,7 @@ func (q *Queries) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreSc
|
|||||||
&i.CreatedByUserID,
|
&i.CreatedByUserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -188,7 +193,7 @@ func (q *Queries) InsertScheduledJobRun(ctx context.Context, arg InsertScheduled
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many
|
const listDueScheduledJobs = `-- name: ListDueScheduledJobs :many
|
||||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
|
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
|
||||||
WHERE enabled
|
WHERE enabled
|
||||||
AND next_run_at IS NOT NULL
|
AND next_run_at IS NOT NULL
|
||||||
AND next_run_at <= $1
|
AND next_run_at <= $1
|
||||||
@@ -218,6 +223,7 @@ func (q *Queries) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Tim
|
|||||||
&i.CreatedByUserID,
|
&i.CreatedByUserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -272,7 +278,7 @@ func (q *Queries) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJob
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listScheduledJobs = `-- name: ListScheduledJobs :many
|
const listScheduledJobs = `-- name: ListScheduledJobs :many
|
||||||
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at FROM core.scheduled_jobs
|
SELECT id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id FROM core.scheduled_jobs
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`
|
`
|
||||||
@@ -301,6 +307,7 @@ func (q *Queries) ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([
|
|||||||
&i.CreatedByUserID,
|
&i.CreatedByUserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -319,7 +326,7 @@ SET last_run_at = $2,
|
|||||||
next_run_at = $4,
|
next_run_at = $4,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
|
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type MarkScheduledJobRanParams struct {
|
type MarkScheduledJobRanParams struct {
|
||||||
@@ -352,6 +359,7 @@ func (q *Queries) MarkScheduledJobRan(ctx context.Context, arg MarkScheduledJobR
|
|||||||
&i.CreatedByUserID,
|
&i.CreatedByUserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -386,24 +394,27 @@ SET name = $3,
|
|||||||
next_run_at = $7,
|
next_run_at = $7,
|
||||||
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
|
secret = CASE WHEN $8::boolean THEN $9 ELSE secret END,
|
||||||
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
|
payload = CASE WHEN $10::boolean THEN $11 ELSE payload END,
|
||||||
|
function_id = CASE WHEN $12::boolean THEN $13 ELSE function_id END,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND project_id = $2
|
AND project_id = $2
|
||||||
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at
|
RETURNING id, project_id, name, schedule, url, secret, payload, enabled, last_run_at, last_status, next_run_at, created_by_user_id, created_at, updated_at, function_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateScheduledJobParams struct {
|
type UpdateScheduledJobParams struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Schedule string `json:"schedule"`
|
Schedule string `json:"schedule"`
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
|
NextRunAt pgtype.Timestamptz `json:"next_run_at"`
|
||||||
Column8 bool `json:"column_8"`
|
Column8 bool `json:"column_8"`
|
||||||
Secret []byte `json:"secret"`
|
Secret []byte `json:"secret"`
|
||||||
Column10 bool `json:"column_10"`
|
Column10 bool `json:"column_10"`
|
||||||
Payload []byte `json:"payload"`
|
Payload []byte `json:"payload"`
|
||||||
|
Column12 bool `json:"column_12"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) {
|
func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) {
|
||||||
@@ -419,6 +430,8 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob
|
|||||||
arg.Secret,
|
arg.Secret,
|
||||||
arg.Column10,
|
arg.Column10,
|
||||||
arg.Payload,
|
arg.Payload,
|
||||||
|
arg.Column12,
|
||||||
|
arg.FunctionID,
|
||||||
)
|
)
|
||||||
var i CoreScheduledJob
|
var i CoreScheduledJob
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -436,6 +449,7 @@ func (q *Queries) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJob
|
|||||||
&i.CreatedByUserID,
|
&i.CreatedByUserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.FunctionID,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -140,6 +140,18 @@ func (ns NullCoreProjectRole) Value() (driver.Value, error) {
|
|||||||
return string(ns.CoreProjectRole), nil
|
return string(ns.CoreProjectRole), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreAlertRule struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Config []byte `json:"config"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
State []byte `json:"state"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreApiKey struct {
|
type CoreApiKey struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -231,6 +243,17 @@ type CoreDocument struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreEmailLog struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID pgtype.UUID `json:"project_id"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
ToEmail string `json:"to_email"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreEvent struct {
|
type CoreEvent struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -243,6 +266,45 @@ type CoreEvent struct {
|
|||||||
Ts pgtype.Timestamptz `json:"ts"`
|
Ts pgtype.Timestamptz `json:"ts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreFunction struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Runtime string `json:"runtime"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
EventPattern string `json:"event_pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoreFunctionRun struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
FunctionID uuid.UUID `json:"function_id"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExitCode *int32 `json:"exit_code"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
DurationMs int32 `json:"duration_ms"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoreInboundHook struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
JobID pgtype.UUID `json:"job_id"`
|
||||||
|
Secret []byte `json:"secret"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastReceivedAt pgtype.Timestamptz `json:"last_received_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreIntegration struct {
|
type CoreIntegration struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -299,6 +361,18 @@ type CoreProjectInvitation struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreProjectLogin struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
App string `json:"app"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Ciphertext []byte `json:"ciphertext"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoreProjectMember struct {
|
type CoreProjectMember struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -307,6 +381,17 @@ type CoreProjectMember struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
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 {
|
type CoreScheduledJob struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
@@ -322,6 +407,7 @@ type CoreScheduledJob struct {
|
|||||||
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
FunctionID pgtype.UUID `json:"function_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoreScheduledJobRun struct {
|
type CoreScheduledJobRun struct {
|
||||||
@@ -336,6 +422,14 @@ type CoreScheduledJobRun struct {
|
|||||||
FinishedAt pgtype.Timestamptz `json:"finished_at"`
|
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 {
|
type CoreUser struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
AuthSubject string `json:"auth_subject"`
|
AuthSubject string `json:"auth_subject"`
|
||||||
|
|||||||
@@ -241,6 +241,23 @@ func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getOrganizationByID = `-- name: GetOrganizationByID :one
|
||||||
|
SELECT id, slug, name, created_at FROM core.organizations
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getOrganizationByID, id)
|
||||||
|
var i CoreOrganization
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Name,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
|
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
|
||||||
SELECT
|
SELECT
|
||||||
om.id, om.organization_id, om.user_id, om.role, om.created_at,
|
om.id, om.organization_id, om.user_id, om.role, om.created_at,
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: project_logins.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteProjectLogin = `-- name: DeleteProjectLogin :one
|
||||||
|
DELETE FROM core.project_logins
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND app = $2
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteProjectLoginParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
App string `json:"app"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteProjectLogin(ctx context.Context, arg DeleteProjectLoginParams) (uuid.UUID, error) {
|
||||||
|
row := q.db.QueryRow(ctx, deleteProjectLogin, arg.ProjectID, arg.App)
|
||||||
|
var id uuid.UUID
|
||||||
|
err := row.Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProjectLogin = `-- name: GetProjectLogin :one
|
||||||
|
SELECT id, project_id, app, username, ciphertext, origin, notes, created_at, updated_at FROM core.project_logins
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND app = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetProjectLoginParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
App string `json:"app"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetProjectLogin(ctx context.Context, arg GetProjectLoginParams) (CoreProjectLogin, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getProjectLogin, arg.ProjectID, arg.App)
|
||||||
|
var i CoreProjectLogin
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.App,
|
||||||
|
&i.Username,
|
||||||
|
&i.Ciphertext,
|
||||||
|
&i.Origin,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listProjectLogins = `-- name: ListProjectLogins :many
|
||||||
|
SELECT id, project_id, app, username, origin, notes, created_at, updated_at
|
||||||
|
FROM core.project_logins
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY app ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListProjectLoginsRow struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
App string `json:"app"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListProjectLogins(ctx context.Context, projectID uuid.UUID) ([]ListProjectLoginsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listProjectLogins, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListProjectLoginsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListProjectLoginsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.App,
|
||||||
|
&i.Username,
|
||||||
|
&i.Origin,
|
||||||
|
&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 upsertProjectLogin = `-- name: UpsertProjectLogin :one
|
||||||
|
INSERT INTO core.project_logins (
|
||||||
|
project_id,
|
||||||
|
app,
|
||||||
|
username,
|
||||||
|
ciphertext,
|
||||||
|
origin,
|
||||||
|
notes
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (project_id, app) DO UPDATE SET
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
ciphertext = EXCLUDED.ciphertext,
|
||||||
|
origin = EXCLUDED.origin,
|
||||||
|
notes = EXCLUDED.notes,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, project_id, app, username, ciphertext, origin, notes, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertProjectLoginParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
App string `json:"app"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Ciphertext []byte `json:"ciphertext"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertProjectLogin(ctx context.Context, arg UpsertProjectLoginParams) (CoreProjectLogin, error) {
|
||||||
|
row := q.db.QueryRow(ctx, upsertProjectLogin,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.App,
|
||||||
|
arg.Username,
|
||||||
|
arg.Ciphertext,
|
||||||
|
arg.Origin,
|
||||||
|
arg.Notes,
|
||||||
|
)
|
||||||
|
var i CoreProjectLogin
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.App,
|
||||||
|
&i.Username,
|
||||||
|
&i.Ciphertext,
|
||||||
|
&i.Origin,
|
||||||
|
&i.Notes,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -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,45 @@ type Querier interface {
|
|||||||
CountOrganizations(ctx context.Context) (int64, error)
|
CountOrganizations(ctx context.Context) (int64, error)
|
||||||
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
|
CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||||
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
|
CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error)
|
||||||
|
CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error)
|
||||||
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
|
CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error)
|
||||||
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
|
CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error)
|
||||||
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
|
CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error)
|
||||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
||||||
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
||||||
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
||||||
|
CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error)
|
||||||
|
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
|
||||||
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
||||||
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
||||||
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
|
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
|
||||||
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
|
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
|
||||||
CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error)
|
CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error)
|
||||||
CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error)
|
CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error)
|
||||||
|
DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error
|
||||||
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
|
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
|
||||||
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||||
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
||||||
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
||||||
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
||||||
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
||||||
|
DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error
|
||||||
|
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
|
||||||
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
||||||
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||||
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||||
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
||||||
|
DeleteProjectLogin(ctx context.Context, arg DeleteProjectLoginParams) (uuid.UUID, error)
|
||||||
|
DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error)
|
||||||
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (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)
|
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error)
|
||||||
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
|
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
|
||||||
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
|
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
|
||||||
FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error)
|
FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||||
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
|
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
|
||||||
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
|
GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error)
|
||||||
|
GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error)
|
||||||
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
|
GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error)
|
||||||
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||||
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
|
GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error)
|
||||||
@@ -60,23 +70,36 @@ type Querier interface {
|
|||||||
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
||||||
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
||||||
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
||||||
|
GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error)
|
||||||
|
GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error)
|
||||||
|
GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error)
|
||||||
|
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error)
|
||||||
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
|
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
|
||||||
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||||
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
||||||
|
GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||||
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
|
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
|
||||||
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
||||||
|
GetProjectLogin(ctx context.Context, arg GetProjectLoginParams) (CoreProjectLogin, error)
|
||||||
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
|
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
|
||||||
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, 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)
|
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)
|
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
|
||||||
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
|
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
|
||||||
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
|
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
|
||||||
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
|
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
|
||||||
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
|
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
|
||||||
|
InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error)
|
||||||
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
|
||||||
|
InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error)
|
||||||
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error)
|
||||||
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
|
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
|
||||||
|
// Per-component most recent heartbeat — heartbeat_silence detection.
|
||||||
|
LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error)
|
||||||
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
|
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
|
||||||
|
ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error)
|
||||||
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
||||||
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
||||||
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
|
ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error)
|
||||||
@@ -86,17 +109,27 @@ type Querier interface {
|
|||||||
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
|
ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error)
|
||||||
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
||||||
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
|
ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error)
|
||||||
|
ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error)
|
||||||
|
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
||||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||||
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
||||||
|
ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error)
|
||||||
|
ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error)
|
||||||
|
ListFunctionsForEvent(ctx context.Context, arg ListFunctionsForEventParams) ([]CoreFunction, error)
|
||||||
|
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
|
||||||
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
||||||
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
||||||
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
||||||
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
|
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
|
||||||
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
|
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
|
||||||
|
ListProjectLogins(ctx context.Context, projectID uuid.UUID) ([]ListProjectLoginsRow, error)
|
||||||
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, 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)
|
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
|
||||||
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
|
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
|
||||||
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error)
|
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error)
|
||||||
|
ListSettings(ctx context.Context) ([]ListSettingsRow, error)
|
||||||
ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error)
|
ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error)
|
||||||
ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error)
|
ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error)
|
||||||
ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error)
|
ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error)
|
||||||
@@ -107,10 +140,13 @@ type Querier interface {
|
|||||||
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
|
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
|
||||||
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
||||||
PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error
|
PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error
|
||||||
|
// Per-component error count inside a sliding window — error_spike detection.
|
||||||
|
RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error)
|
||||||
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
|
RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error)
|
||||||
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error)
|
||||||
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
|
RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error
|
||||||
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
|
RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error)
|
||||||
|
SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error
|
||||||
SweepExpiredAuditLogs(ctx context.Context) (int64, error)
|
SweepExpiredAuditLogs(ctx context.Context) (int64, error)
|
||||||
// Per-project retention sweeps. A retention of 0 days disables the sweep for
|
// Per-project retention sweeps. A retention of 0 days disables the sweep for
|
||||||
// that stream. Pending webhook deliveries are never swept — they may still be
|
// that stream. Pending webhook deliveries are never swept — they may still be
|
||||||
@@ -121,9 +157,12 @@ type Querier interface {
|
|||||||
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
|
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
|
||||||
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
|
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
|
||||||
TouchAPIKey(ctx context.Context, id uuid.UUID) error
|
TouchAPIKey(ctx context.Context, id uuid.UUID) error
|
||||||
|
TouchInboundHook(ctx context.Context, id uuid.UUID) error
|
||||||
|
UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error)
|
||||||
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
||||||
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
||||||
|
UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error)
|
||||||
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
|
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
|
||||||
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
|
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
|
||||||
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
||||||
@@ -132,6 +171,9 @@ type Querier interface {
|
|||||||
UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
|
UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
|
||||||
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
|
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
|
||||||
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
|
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
|
||||||
|
UpsertProjectLogin(ctx context.Context, arg UpsertProjectLoginParams) (CoreProjectLogin, 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)
|
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
|
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
AND enabled
|
AND enabled
|
||||||
AND (cardinality(events) = 0 OR $2::text = ANY(events))
|
AND (cardinality(events) = 0 OR EXISTS (
|
||||||
|
SELECT 1 FROM unnest(events) AS e
|
||||||
|
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
|
||||||
|
))
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListWebhooksForEventParams struct {
|
type ListWebhooksForEventParams struct {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *HTTPHandler) alertError(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "alert rule not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "invalid") || strings.Contains(msg, "must be") || strings.Contains(msg, "already exists") || strings.Contains(msg, "too long") {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listAlertRules(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListAlertRules(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.alertError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) createAlertRule(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.UpsertAlertRuleInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.CreateAlertRule(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.alertError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) updateAlertRule(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ruleID, err := uuid.Parse(c.Param("ruleID"))
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.UpsertAlertRuleInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.UpdateAlertRule(c.Request.Context(), actor, projectID, ruleID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.alertError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteAlertRule(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ruleID, err := uuid.Parse(c.Param("ruleID"))
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteAlertRule(c.Request.Context(), actor, projectID, ruleID, middleware.RequestIDFromContext(c)); err != nil {
|
||||||
|
h.alertError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// publishEvent accepts a client-authored domain event (custom.* namespace)
|
||||||
|
// and fans it out through the standard publishEvent path — realtime
|
||||||
|
// subscribers, matching webhooks, and event_pattern functions all fire.
|
||||||
|
func (h *HTTPHandler) publishEvent(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Type string `json:"type" validate:"required"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.PublishProjectEvent(c.Request.Context(), actor, projectID, body.Type, body.Data); err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"published": body.Type})
|
||||||
|
}
|
||||||
|
|
||||||
|
// realtimePresence returns the count of clients currently subscribed to the
|
||||||
|
// project's realtime stream.
|
||||||
|
func (h *HTTPHandler) realtimePresence(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.Platform.Presence(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"online": n})
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listFunctions(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.Platform.ListFunctions(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) createFunction(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.CreateFunctionInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn, err := h.Platform.CreateFunction(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) getFunction(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn, code, err := h.Platform.GetFunctionCode(c.Request.Context(), actor, functionID)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"function": fn, "code": code})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) updateFunction(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.UpdateFunctionInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn, err := h.Platform.UpdateFunction(c.Request.Context(), actor, functionID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteFunction(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteFunction(c.Request.Context(), actor, functionID, middleware.RequestIDFromContext(c)); err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) invokeFunction(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
// An empty body is a valid invoke — defaults to {}.
|
||||||
|
if c.Request.Body != nil && c.Request.ContentLength > 0 {
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_json", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run, err := h.Platform.InvokeFunction(c.Request.Context(), actor, functionID, body.Payload, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := http.StatusOK
|
||||||
|
if run.Status != "success" {
|
||||||
|
status = http.StatusBadGateway
|
||||||
|
}
|
||||||
|
c.JSON(status, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listFunctionRuns(c *gin.Context) {
|
||||||
|
actor, _, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
functionID, ok := parseUUIDParam(c, "functionID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.ParseInt(c.DefaultQuery("limit", "50"), 10, 64)
|
||||||
|
runs, err := h.Platform.ListFunctionRuns(c.Request.Context(), actor, functionID, limit)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": runs})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -11,7 +12,9 @@ import (
|
|||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/observability"
|
"github.com/tdvorak/primora/apps/backend/internal/observability"
|
||||||
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
@@ -20,6 +23,7 @@ import (
|
|||||||
|
|
||||||
type HTTPHandler struct {
|
type HTTPHandler struct {
|
||||||
Platform *services.PlatformService
|
Platform *services.PlatformService
|
||||||
|
Settings *services.SettingsService
|
||||||
Validate *validator.Validate
|
Validate *validator.Validate
|
||||||
Metrics *observability.Metrics
|
Metrics *observability.Metrics
|
||||||
Readiness func(*gin.Context) map[string]any
|
Readiness func(*gin.Context) map[string]any
|
||||||
@@ -33,6 +37,11 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
|
|
||||||
api := router.Group("/api/v1")
|
api := router.Group("/api/v1")
|
||||||
api.GET("/me", h.me)
|
api.GET("/me", h.me)
|
||||||
|
api.GET("/context", h.actorContext)
|
||||||
|
api.GET("/instance/public", h.instancePublic)
|
||||||
|
api.GET("/instance/settings", h.listInstanceSettings)
|
||||||
|
api.PUT("/instance/settings/:key", h.updateInstanceSetting)
|
||||||
|
api.DELETE("/instance/settings/:key", h.deleteInstanceSetting)
|
||||||
api.POST("/bootstrap", h.bootstrap)
|
api.POST("/bootstrap", h.bootstrap)
|
||||||
api.GET("/organizations", h.organizations)
|
api.GET("/organizations", h.organizations)
|
||||||
api.POST("/organizations", h.createOrganization)
|
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.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
||||||
api.OPTIONS("/ingest", h.ingestOptions)
|
api.OPTIONS("/ingest", h.ingestOptions)
|
||||||
api.POST("/ingest", h.ingest)
|
api.POST("/ingest", h.ingest)
|
||||||
|
api.POST("/hooks/:token", h.receiveInboundHook)
|
||||||
|
api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks)
|
||||||
|
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
|
||||||
|
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
|
||||||
|
api.GET("/projects/:projectID/emails", h.listEmailLog)
|
||||||
|
api.GET("/projects/:projectID/functions", h.listFunctions)
|
||||||
|
api.POST("/projects/:projectID/functions", h.createFunction)
|
||||||
|
api.GET("/projects/:projectID/functions/:functionID", h.getFunction)
|
||||||
|
api.PUT("/projects/:projectID/functions/:functionID", h.updateFunction)
|
||||||
|
api.DELETE("/projects/:projectID/functions/:functionID", h.deleteFunction)
|
||||||
|
api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction)
|
||||||
|
api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns)
|
||||||
api.GET("/projects/:projectID/events", h.listEvents)
|
api.GET("/projects/:projectID/events", h.listEvents)
|
||||||
|
api.POST("/projects/:projectID/events", h.publishEvent)
|
||||||
api.GET("/projects/:projectID/issues", h.listIssues)
|
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||||
api.GET("/projects/:projectID/components", h.listComponents)
|
api.GET("/projects/:projectID/components", h.listComponents)
|
||||||
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
|
api.DELETE("/projects/:projectID/components/:componentID", h.deleteComponent)
|
||||||
@@ -111,10 +133,25 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob)
|
api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob)
|
||||||
api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns)
|
api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns)
|
||||||
api.POST("/projects/:projectID/jobs/:jobID/run", h.runJob)
|
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/logins", h.listProjectLogins)
|
||||||
|
api.PUT("/projects/:projectID/logins/:app", h.setProjectLogin)
|
||||||
|
api.DELETE("/projects/:projectID/logins/:app", h.deleteProjectLogin)
|
||||||
|
api.POST("/projects/:projectID/logins/:app/reveal", h.revealProjectLogin)
|
||||||
|
|
||||||
|
api.GET("/projects/:projectID/alerts", h.listAlertRules)
|
||||||
|
api.POST("/projects/:projectID/alerts", h.createAlertRule)
|
||||||
|
api.PUT("/projects/:projectID/alerts/:ruleID", h.updateAlertRule)
|
||||||
|
api.DELETE("/projects/:projectID/alerts/:ruleID", h.deleteAlertRule)
|
||||||
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
|
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
|
||||||
|
api.GET("/projects/:projectID/realtime/presence", h.realtimePresence)
|
||||||
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
||||||
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
||||||
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
|
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
|
||||||
|
api.POST("/buckets/:bucketID/object-presigns", h.presignObject)
|
||||||
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
|
api.GET("/buckets/:bucketID/objects/*objectKey", h.downloadObject)
|
||||||
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
|
api.PATCH("/buckets/:bucketID/objects/*objectKey", h.updateObject)
|
||||||
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
|
api.DELETE("/buckets/:bucketID/objects/*objectKey", h.deleteObject)
|
||||||
@@ -151,6 +188,19 @@ func (h *HTTPHandler) me(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) actorContext(c *gin.Context) {
|
||||||
|
actor, ok := middleware.RequireActor(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ActorContext(c.Request.Context(), actor)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) bootstrap(c *gin.Context) {
|
func (h *HTTPHandler) bootstrap(c *gin.Context) {
|
||||||
actor, ok := middleware.RequireActor(c)
|
actor, ok := middleware.RequireActor(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -864,6 +914,43 @@ func (h *HTTPHandler) updateObject(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type presignObjectRequest struct {
|
||||||
|
Key string `json:"key" validate:"required"`
|
||||||
|
Op string `json:"op" validate:"omitempty,oneof=download upload"`
|
||||||
|
TTLSeconds int `json:"ttl_seconds" validate:"omitempty,min=60,max=3600"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// presignObject returns a short-lived URL clients call against storage
|
||||||
|
// directly — keeps large transfers off the backend. S3 driver only.
|
||||||
|
func (h *HTTPHandler) presignObject(c *gin.Context) {
|
||||||
|
actor, ok := middleware.RequireActor(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bucketID, ok := parseUUIDParam(c, "bucketID")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body presignObjectRequest
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Platform.PresignObject(c.Request.Context(), actor, bucketID, strings.TrimSpace(body.Key), body.Op, body.TTLSeconds)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, services.ErrPresignUnsupported) {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "presign_unsupported", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) deleteObject(c *gin.Context) {
|
func (h *HTTPHandler) deleteObject(c *gin.Context) {
|
||||||
actor, ok := middleware.RequireActor(c)
|
actor, ok := middleware.RequireActor(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1048,12 +1135,19 @@ func (h *HTTPHandler) listDocuments(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
actor, _ := middleware.ActorFromContext(c)
|
actor, _ := middleware.ActorFromContext(c)
|
||||||
result, err := h.Platform.ListDocuments(c.Request.Context(), actor, collectionID, limit, offset)
|
var result []db.CoreDocument
|
||||||
if err != nil {
|
var total int64
|
||||||
h.handleError(c, err)
|
var err error
|
||||||
return
|
// 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 {
|
if err != nil {
|
||||||
h.handleError(c, err)
|
h.handleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -1190,17 +1284,58 @@ func (h *HTTPHandler) bindAndValidate(c *gin.Context, target any) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if err := h.Validate.Struct(target); err != nil {
|
if err := h.Validate.Struct(target); err != nil {
|
||||||
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", err.Error())
|
var valErrs validator.ValidationErrors
|
||||||
|
if errors.As(err, &valErrs) && len(valErrs) > 0 {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", formatFieldError(valErrs[0]))
|
||||||
|
} else {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", "invalid request body")
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatFieldError(f validator.FieldError) string {
|
||||||
|
if f.Tag() == "required" {
|
||||||
|
return fmt.Sprintf("%s is required", strings.ToLower(f.Field()))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s failed %s validation", strings.ToLower(f.Field()), f.Tag())
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) handleError(c *gin.Context, err error) {
|
func (h *HTTPHandler) handleError(c *gin.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, io.EOF):
|
case errors.Is(err, io.EOF):
|
||||||
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
|
||||||
|
case errors.Is(err, pgx.ErrNoRows):
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "resource not found")
|
||||||
default:
|
default:
|
||||||
|
var inputErr *services.InputError
|
||||||
|
if errors.As(err, &inputErr) {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", inputErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var valErrs validator.ValidationErrors
|
||||||
|
if errors.As(err, &valErrs) && len(valErrs) > 0 {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", formatFieldError(valErrs[0]))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) {
|
||||||
|
// Structured codes — never leak raw SQLSTATE text or constraint names.
|
||||||
|
switch pgErr.Code {
|
||||||
|
case "23505":
|
||||||
|
apperrors.Abort(c, http.StatusConflict, "conflict", "resource already exists")
|
||||||
|
case "23503":
|
||||||
|
apperrors.Abort(c, http.StatusConflict, "conflict", "referenced resource does not exist")
|
||||||
|
case "22001", "54000":
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "value too large")
|
||||||
|
case "22021":
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "input is not valid UTF-8")
|
||||||
|
default:
|
||||||
|
apperrors.Abort(c, http.StatusInternalServerError, "request_failed", "database error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
message := strings.ToLower(err.Error())
|
message := strings.ToLower(err.Error())
|
||||||
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
|
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
|
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||||
@@ -104,4 +109,46 @@ func TestHandleErrorStatusMapping(t *testing.T) {
|
|||||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("maps missing rows to not found", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, recorder := newTestContext("")
|
||||||
|
handler.handleError(ctx, pgx.ErrNoRows)
|
||||||
|
if recorder.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("maps InputError to bad request with message", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, recorder := newTestContext("")
|
||||||
|
handler.handleError(ctx, services.NewInputError("webhook url must be https"))
|
||||||
|
if recorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "webhook url must be https") {
|
||||||
|
t.Fatalf("expected reason in body, got %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("maps unique violation to conflict without leaking", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, recorder := newTestContext("")
|
||||||
|
handler.handleError(ctx, &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint \"projects_slug_key\""})
|
||||||
|
if recorder.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(recorder.Body.String(), "projects_slug_key") || strings.Contains(recorder.Body.String(), "23505") {
|
||||||
|
t.Fatalf("raw constraint leaked: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("maps unknown pg error to internal error", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx, recorder := newTestContext("")
|
||||||
|
handler.handleError(ctx, &pgconn.PgError{Code: "XX000", Message: "internal"})
|
||||||
|
if recorder.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxInboundBody = 256 * 1024 // external POSTs capped at 256 KiB
|
||||||
|
|
||||||
|
func (h *HTTPHandler) inboundError(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "hook not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
switch {
|
||||||
|
case strings.Contains(msg, "invalid signature"):
|
||||||
|
apperrors.Abort(c, http.StatusUnauthorized, "unauthorized", msg)
|
||||||
|
case strings.Contains(msg, "invalid") || strings.Contains(msg, "already exists") || strings.Contains(msg, "disabled") || strings.Contains(msg, "requires") || strings.Contains(msg, "not found in this project"):
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||||
|
default:
|
||||||
|
h.handleError(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listInboundHooks(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListInboundHooks(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.inboundError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) createInboundHook(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.CreateInboundHookInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.CreateInboundHook(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.inboundError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteInboundHook(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hookID, err := uuid.Parse(c.Param("hookID"))
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid hook id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteInboundHook(c.Request.Context(), actor, projectID, hookID, middleware.RequestIDFromContext(c)); err != nil {
|
||||||
|
h.inboundError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listEmailLog(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var limit int64
|
||||||
|
if raw := c.Query("limit"); raw != "" {
|
||||||
|
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListEmailLog(c.Request.Context(), actor, projectID, limit)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// receiveInboundHook is the public ingest endpoint — the URL token is the
|
||||||
|
// credential, so no actor is required.
|
||||||
|
func (h *HTTPHandler) receiveInboundHook(c *gin.Context) {
|
||||||
|
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxInboundBody))
|
||||||
|
if err != nil {
|
||||||
|
apperrors.Abort(c, http.StatusRequestEntityTooLarge, "too_large", "body exceeds 256 KiB")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ReceiveInboundHook(c.Request.Context(), c.Param("token"), c.GetHeader("X-Primora-Signature"), body)
|
||||||
|
if err != nil {
|
||||||
|
h.inboundError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,77 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *HTTPHandler) loginError(c *gin.Context, err error) {
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "invalid app id") || strings.Contains(msg, "username is required") {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.secretError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listProjectLogins(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListProjectLogins(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.loginError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) setProjectLogin(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.SetProjectLoginInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.App = c.Param("app")
|
||||||
|
result, err := h.Platform.SetProjectLogin(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.loginError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteProjectLogin(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteProjectLogin(c.Request.Context(), actor, projectID, c.Param("app"), middleware.RequestIDFromContext(c)); err != nil {
|
||||||
|
h.loginError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) revealProjectLogin(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.RevealProjectLogin(c.Request.Context(), actor, projectID, c.Param("app"), middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.loginError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *HTTPHandler) secretError(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "secret not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "invalid secret name") {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "encryption is not configured") {
|
||||||
|
apperrors.Abort(c, http.StatusServiceUnavailable, "unavailable", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listProjectSecrets(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListProjectSecrets(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.secretError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) setProjectSecret(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.SetProjectSecretInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.Name = c.Param("name")
|
||||||
|
result, err := h.Platform.SetProjectSecret(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.secretError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteProjectSecret(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c)); err != nil {
|
||||||
|
h.secretError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) revealProjectSecret(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.RevealProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.secretError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ type AuthMiddleware struct {
|
|||||||
Redis *redis.Client
|
Redis *redis.Client
|
||||||
Verifier *auth.Verifier
|
Verifier *auth.Verifier
|
||||||
RateLimits RateLimitConfig
|
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 {
|
type RateLimitConfig struct {
|
||||||
@@ -44,6 +47,16 @@ type RateLimitConfig struct {
|
|||||||
UserPerMinute int
|
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 {
|
func RequestID() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
requestID := c.Request.Header.Get("X-Request-ID")
|
requestID := c.Request.Header.Get("X-Request-ID")
|
||||||
@@ -105,7 +118,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
|||||||
if actor.UserID != nil {
|
if actor.UserID != nil {
|
||||||
userIdentity = actor.UserID.String()
|
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
|
return
|
||||||
}
|
}
|
||||||
c.Set(actorKey, actor)
|
c.Set(actorKey, actor)
|
||||||
@@ -123,7 +136,7 @@ func (m AuthMiddleware) ResolveActor() gin.HandlerFunc {
|
|||||||
apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error())
|
apperrors.Abort(c, http.StatusUnauthorized, "invalid_api_key", err.Error())
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
if need := requiredAPIKeyScope(c.Request.Method, c.Request.URL.Path); !actor.HasScope(need) {
|
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
|
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 {
|
func (r *CoreRepository) WithTx(ctx context.Context, fn func(*db.Queries) error) error {
|
||||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||||
if err != nil {
|
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 {
|
func (r *CoreRepository) TouchAPIKey(ctx context.Context, id uuid.UUID) error {
|
||||||
return r.queries.TouchAPIKey(ctx, id)
|
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 {
|
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
|
||||||
t.Error("expected unknown event rejected")
|
t.Error("expected unknown event rejected")
|
||||||
}
|
}
|
||||||
|
for _, ok := range [][]string{
|
||||||
|
{"*"},
|
||||||
|
{"document.*"},
|
||||||
|
{"custom.deploy.done"},
|
||||||
|
{"custom.*"},
|
||||||
|
{"custom.deploy.*"},
|
||||||
|
} {
|
||||||
|
if _, err := validateWebhookEvents(ok); err != nil {
|
||||||
|
t.Errorf("expected %v accepted, got %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range [][]string{
|
||||||
|
{"banana.*"}, {"custom..*"}, {"custom.UPPER"}, {"document.exploded"},
|
||||||
|
} {
|
||||||
|
if _, err := validateWebhookEvents(bad); err == nil {
|
||||||
|
t.Errorf("expected %v rejected", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrivateWebhookHostHardening(t *testing.T) {
|
||||||
|
private := []string{"localhost", "minio", "echo-svc", "host.docker.internal",
|
||||||
|
"10.0.0.4", "172.16.5.5", "192.168.1.10", "fe80::1", "svc.local", "db.internal"}
|
||||||
|
public := []string{"169.254.169.254", "fd00:ec2::254", "2130706433", "0x7f000001",
|
||||||
|
"8.8.8.8", "example.com", "999999999999"}
|
||||||
|
for _, h := range private {
|
||||||
|
if !isPrivateWebhookHost(h) {
|
||||||
|
t.Errorf("expected %q private", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, h := range public {
|
||||||
|
if isPrivateWebhookHost(h) {
|
||||||
|
t.Errorf("expected %q public/blocked", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
|
|
||||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
@@ -34,6 +35,7 @@ type ScheduledJobSummary struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Schedule string `json:"schedule"`
|
Schedule string `json:"schedule"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
FunctionID *string `json:"function_id,omitempty"`
|
||||||
Payload json.RawMessage `json:"payload"`
|
Payload json.RawMessage `json:"payload"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
HasSecret bool `json:"has_secret"`
|
HasSecret bool `json:"has_secret"`
|
||||||
@@ -58,21 +60,23 @@ type ScheduledJobRunSummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateScheduledJobInput struct {
|
type CreateScheduledJobInput struct {
|
||||||
Name string `json:"name" validate:"required,min=2"`
|
Name string `json:"name" validate:"required,min=2"`
|
||||||
Schedule string `json:"schedule" validate:"required"`
|
Schedule string `json:"schedule" validate:"required"`
|
||||||
URL string `json:"url" validate:"required,url"`
|
URL string `json:"url" validate:"required_without=FunctionID,omitempty,url"`
|
||||||
Secret string `json:"secret"`
|
FunctionID string `json:"function_id" validate:"omitempty,uuid"`
|
||||||
Payload map[string]any `json:"payload"`
|
Secret string `json:"secret"`
|
||||||
Enabled *bool `json:"enabled"`
|
Payload map[string]any `json:"payload"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateScheduledJobInput struct {
|
type UpdateScheduledJobInput struct {
|
||||||
Name *string `json:"name" validate:"omitempty,min=2"`
|
Name *string `json:"name" validate:"omitempty,min=2"`
|
||||||
Schedule *string `json:"schedule"`
|
Schedule *string `json:"schedule"`
|
||||||
URL *string `json:"url" validate:"omitempty,url"`
|
URL *string `json:"url" validate:"omitempty,url"`
|
||||||
Secret *string `json:"secret"`
|
FunctionID *string `json:"function_id" validate:"omitempty,uuid"`
|
||||||
Payload map[string]any `json:"payload"`
|
Secret *string `json:"secret"`
|
||||||
Enabled *bool `json:"enabled"`
|
Payload map[string]any `json:"payload"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
|
func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
|
||||||
@@ -87,6 +91,10 @@ func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
|
|||||||
HasSecret: len(row.Secret) > 0,
|
HasSecret: len(row.Secret) > 0,
|
||||||
CreatedAt: row.CreatedAt.Time,
|
CreatedAt: row.CreatedAt.Time,
|
||||||
}
|
}
|
||||||
|
if row.FunctionID.Valid {
|
||||||
|
id := uuid.UUID(row.FunctionID.Bytes).String()
|
||||||
|
out.FunctionID = &id
|
||||||
|
}
|
||||||
if row.LastRunAt.Valid {
|
if row.LastRunAt.Valid {
|
||||||
t := row.LastRunAt.Time
|
t := row.LastRunAt.Time
|
||||||
out.LastRunAt = &t
|
out.LastRunAt = &t
|
||||||
@@ -175,9 +183,25 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
|
|||||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
return ScheduledJobSummary{}, err
|
return ScheduledJobSummary{}, err
|
||||||
}
|
}
|
||||||
jobURL, err := normalizeWebhookURL(input.URL)
|
jobURL := ""
|
||||||
if err != nil {
|
var functionID pgtype.UUID
|
||||||
return ScheduledJobSummary{}, err
|
if input.FunctionID != "" {
|
||||||
|
fid, err := uuid.Parse(input.FunctionID)
|
||||||
|
if err != nil {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
|
||||||
|
}
|
||||||
|
fn, err := s.repo.Queries().GetFunction(ctx, fid)
|
||||||
|
if err != nil || fn.ProjectID != projectID {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
|
||||||
|
}
|
||||||
|
functionID = pgtype.UUID{Bytes: fid, Valid: true}
|
||||||
|
jobURL = "function://" + fid.String()
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
jobURL, err = normalizeWebhookURL(input.URL)
|
||||||
|
if err != nil {
|
||||||
|
return ScheduledJobSummary{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sched, err := parseSchedule(input.Schedule)
|
sched, err := parseSchedule(input.Schedule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -221,6 +245,7 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
|
|||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
NextRunAt: next,
|
NextRunAt: next,
|
||||||
CreatedByUserID: createdBy,
|
CreatedByUserID: createdBy,
|
||||||
|
FunctionID: functionID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
||||||
@@ -259,6 +284,28 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
|
|||||||
return ScheduledJobSummary{}, err
|
return ScheduledJobSummary{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
rotateFunction := input.FunctionID != nil
|
||||||
|
functionID := row.FunctionID
|
||||||
|
if rotateFunction {
|
||||||
|
raw := strings.TrimSpace(*input.FunctionID)
|
||||||
|
if raw == "" {
|
||||||
|
functionID = pgtype.UUID{} // explicit clear → back to URL delivery
|
||||||
|
if input.URL == nil && strings.HasPrefix(jobURL, "function://") {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("clearing function_id requires a url")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fid, ferr := uuid.Parse(raw)
|
||||||
|
if ferr != nil {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
|
||||||
|
}
|
||||||
|
fn, ferr := s.repo.Queries().GetFunction(ctx, fid)
|
||||||
|
if ferr != nil || fn.ProjectID != projectID {
|
||||||
|
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
|
||||||
|
}
|
||||||
|
functionID = pgtype.UUID{Bytes: fid, Valid: true}
|
||||||
|
jobURL = "function://" + fid.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
enabled := row.Enabled
|
enabled := row.Enabled
|
||||||
if input.Enabled != nil {
|
if input.Enabled != nil {
|
||||||
enabled = *input.Enabled
|
enabled = *input.Enabled
|
||||||
@@ -301,6 +348,8 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
|
|||||||
Secret: secretBytes,
|
Secret: secretBytes,
|
||||||
Column10: rotatePayload,
|
Column10: rotatePayload,
|
||||||
Payload: payload,
|
Payload: payload,
|
||||||
|
Column12: rotateFunction,
|
||||||
|
FunctionID: functionID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
|
||||||
@@ -393,6 +442,7 @@ type jobQueries interface {
|
|||||||
FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error)
|
FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error)
|
||||||
MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error)
|
MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error)
|
||||||
PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error
|
PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error
|
||||||
|
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]db.ListProjectSecretValuesRow, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobScheduler struct {
|
type JobScheduler struct {
|
||||||
@@ -405,8 +455,13 @@ type JobScheduler struct {
|
|||||||
quit chan struct{}
|
quit chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
started sync.Once
|
started sync.Once
|
||||||
|
leader *leaderState
|
||||||
// onEvent fans a finished run out to webhooks and the realtime stream.
|
// onEvent fans a finished run out to webhooks and the realtime stream.
|
||||||
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
|
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
|
||||||
|
// runFn delivers to a function instead of HTTP when job.FunctionID is set.
|
||||||
|
// The function receives the resolved job payload, not the run envelope.
|
||||||
|
// Owned by PlatformService — the scheduler stays transport-agnostic.
|
||||||
|
runFn func(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type jobExec struct {
|
type jobExec struct {
|
||||||
@@ -414,7 +469,7 @@ type jobExec struct {
|
|||||||
run db.CoreScheduledJobRun
|
run db.CoreScheduledJobRun
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)) *JobScheduler {
|
func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), pool *pgxpool.Pool) *JobScheduler {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
}
|
}
|
||||||
@@ -427,6 +482,7 @@ func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger,
|
|||||||
queue: make(chan jobExec, 256),
|
queue: make(chan jobExec, 256),
|
||||||
quit: make(chan struct{}),
|
quit: make(chan struct{}),
|
||||||
onEvent: onEvent,
|
onEvent: onEvent,
|
||||||
|
leader: newLeaderState(pool, leaderKeyScheduler),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,8 +507,13 @@ func (j *JobScheduler) run(ctx context.Context) {
|
|||||||
case exec := <-j.queue:
|
case exec := <-j.queue:
|
||||||
j.execute(exec.job, exec.run)
|
j.execute(exec.job, exec.run)
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
j.scan()
|
// Only the leader fires scheduled runs — manual/hook enqueues are
|
||||||
|
// already scoped to whichever replica received the request.
|
||||||
|
if j.leader.hold(ctx) {
|
||||||
|
j.scan()
|
||||||
|
}
|
||||||
case <-j.quit:
|
case <-j.quit:
|
||||||
|
j.leader.stop(context.Background())
|
||||||
return
|
return
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
@@ -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) {
|
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{
|
payload, _ := json.Marshal(map[string]any{
|
||||||
"event": WebhookEventJobRun,
|
"event": WebhookEventJobRun,
|
||||||
"project_id": job.ProjectID.String(),
|
"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(),
|
"run_id": run.ID.String(),
|
||||||
"triggered_by": run.TriggeredBy,
|
"triggered_by": run.TriggeredBy,
|
||||||
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
"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 := ""
|
secret := ""
|
||||||
if len(job.Secret) > 0 && j.enc != nil {
|
if len(job.Secret) > 0 && j.enc != nil {
|
||||||
if plain, err := j.enc.Decrypt(job.Secret); err == nil {
|
if plain, err := j.enc.Decrypt(job.Secret); err == nil {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Leader election via Postgres advisory locks. The lock is session-scoped, so
|
||||||
|
// the lease pins its pool connection for its entire lifetime — a handoff back
|
||||||
|
// to the pool would silently drop it. A single-replica deployment simply
|
||||||
|
// always holds the lock; there is no feature flag.
|
||||||
|
//
|
||||||
|
// Distinct keys per component so the job scheduler and the alert evaluator
|
||||||
|
// elect independently.
|
||||||
|
const (
|
||||||
|
leaderKeyScheduler int64 = 728201
|
||||||
|
leaderKeyAlerts int64 = 728202
|
||||||
|
)
|
||||||
|
|
||||||
|
type leaderLease struct {
|
||||||
|
conn *pgxpool.Conn
|
||||||
|
key int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquireLeadership returns a lease when this process wins the lock, nil when
|
||||||
|
// another replica holds it, or an error when the check itself failed.
|
||||||
|
func acquireLeadership(ctx context.Context, pool *pgxpool.Pool, key int64) (*leaderLease, error) {
|
||||||
|
conn, err := pool.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var got bool
|
||||||
|
if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&got); err != nil {
|
||||||
|
conn.Release()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !got {
|
||||||
|
conn.Release()
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &leaderLease{conn: conn, key: key}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// alive pings the pinned connection — a dead conn means Postgres dropped the
|
||||||
|
// session and released the lock, so leadership is lost even though the lease
|
||||||
|
// object still exists.
|
||||||
|
func (l *leaderLease) alive(ctx context.Context) bool {
|
||||||
|
var one int
|
||||||
|
return l.conn.QueryRow(ctx, "SELECT 1").Scan(&one) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *leaderLease) release(ctx context.Context) {
|
||||||
|
_, _ = l.conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", l.key)
|
||||||
|
l.conn.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
// leaderState tracks a lease inside a ticker loop — acquire when absent,
|
||||||
|
// verify when held, drop and re-elect when the connection dies.
|
||||||
|
type leaderState struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
key int64
|
||||||
|
lease *leaderLease
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLeaderState(pool *pgxpool.Pool, key int64) *leaderState {
|
||||||
|
return &leaderState{pool: pool, key: key}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hold reports whether this process currently leads. A nil pool means no
|
||||||
|
// election configured (tests, embedded use) — always lead.
|
||||||
|
func (l *leaderState) hold(ctx context.Context) bool {
|
||||||
|
if l == nil || l.pool == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if l.lease != nil {
|
||||||
|
if l.lease.alive(ctx) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
l.lease.release(ctx)
|
||||||
|
l.lease = nil
|
||||||
|
}
|
||||||
|
lease, err := acquireLeadership(ctx, l.pool, l.key)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
l.lease = lease
|
||||||
|
return lease != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *leaderState) stop(ctx context.Context) {
|
||||||
|
if l != nil && l.lease != nil {
|
||||||
|
l.lease.release(ctx)
|
||||||
|
l.lease = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +11,9 @@ import (
|
|||||||
"github.com/tdvorak/primora/apps/backend/internal/config"
|
"github.com/tdvorak/primora/apps/backend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Mailer is transport-only: Resend when configured, plain SMTP otherwise.
|
||||||
|
// Templates render (subject, body) from data; sends are logged to
|
||||||
|
// core.email_log by the service layer so operators can see what went out.
|
||||||
type Mailer struct {
|
type Mailer struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
resend *resend.Client
|
resend *resend.Client
|
||||||
@@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer {
|
|||||||
return &Mailer{cfg: cfg, resend: resendClient}
|
return &Mailer{cfg: cfg, resend: resendClient}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error {
|
// mailTemplate renders a subject + plaintext body from template data.
|
||||||
subject := fmt.Sprintf("You were invited to %s on Primora", organizationName)
|
type mailTemplate func(data map[string]string) (subject string, body string)
|
||||||
text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n"
|
|
||||||
|
var mailTemplates = map[string]mailTemplate{
|
||||||
|
"invitation": func(d map[string]string) (string, string) {
|
||||||
|
subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"])
|
||||||
|
body := fmt.Sprintf(
|
||||||
|
"You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n",
|
||||||
|
d["invite_url"],
|
||||||
|
)
|
||||||
|
return subject, body
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMailTemplate(template string, data map[string]string) (string, string, error) {
|
||||||
|
tpl, ok := mailTemplates[template]
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("unknown email template %q", template)
|
||||||
|
}
|
||||||
|
subject, body := tpl(data)
|
||||||
|
return subject, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error {
|
||||||
if m.resend != nil {
|
if m.resend != nil {
|
||||||
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
_, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{
|
||||||
From: m.cfg.MailFrom,
|
From: m.cfg.MailFrom,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderMailTemplateInvitation(t *testing.T) {
|
||||||
|
subject, body, err := renderMailTemplate("invitation", map[string]string{
|
||||||
|
"organization": "Acme Corp",
|
||||||
|
"invite_url": "https://app.primora.dev/accept?token=abc",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(subject, "Acme Corp") {
|
||||||
|
t.Fatalf("subject missing organization: %q", subject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "https://app.primora.dev/accept?token=abc") {
|
||||||
|
t.Fatalf("body missing invite url: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMailTemplateUnknown(t *testing.T) {
|
||||||
|
if _, _, err := renderMailTemplate("does-not-exist", nil); err == nil {
|
||||||
|
t.Fatal("expected error for unknown template")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractEmail(t *testing.T) {
|
||||||
|
if got := extractEmail("Primora <[email protected]>"); got != "[email protected]" {
|
||||||
|
t.Fatalf("extractEmail = %q", got)
|
||||||
|
}
|
||||||
|
if got := extractEmail("[email protected]"); got != "[email protected]" {
|
||||||
|
t.Fatalf("extractEmail bare = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -29,7 +32,7 @@ import (
|
|||||||
|
|
||||||
type PlatformService struct {
|
type PlatformService struct {
|
||||||
repo *repositories.CoreRepository
|
repo *repositories.CoreRepository
|
||||||
store *storage.LocalStore
|
store storage.Store
|
||||||
mailer *Mailer
|
mailer *Mailer
|
||||||
publicURL string
|
publicURL string
|
||||||
dbx *dbx.Client
|
dbx *dbx.Client
|
||||||
@@ -38,6 +41,13 @@ type PlatformService struct {
|
|||||||
enc *secrets.Encryptor
|
enc *secrets.Encryptor
|
||||||
dispatcher *WebhookDispatcher
|
dispatcher *WebhookDispatcher
|
||||||
scheduler *JobScheduler
|
scheduler *JobScheduler
|
||||||
|
alerts *alertEvaluator
|
||||||
|
functions FunctionRunner
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
// presenceMu guards presence: realtime subscriber counts per project.
|
||||||
|
presenceMu sync.Mutex
|
||||||
|
presence map[uuid.UUID]int
|
||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapInput struct {
|
type BootstrapInput struct {
|
||||||
@@ -223,17 +233,84 @@ type InvitationSummary struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
|
func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *PlatformService {
|
||||||
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc}
|
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions, logger: logger, presence: map[uuid.UUID]int{}}
|
||||||
if enc != nil {
|
if enc != nil {
|
||||||
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
|
||||||
s.dispatcher.Start(context.Background())
|
s.dispatcher.Start(context.Background())
|
||||||
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent)
|
s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent, repo.Pool())
|
||||||
|
s.scheduler.runFn = s.runJobFunction
|
||||||
s.scheduler.Start(context.Background())
|
s.scheduler.Start(context.Background())
|
||||||
|
s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool())
|
||||||
|
s.alerts.Start(context.Background())
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendTemplatedEmail renders a registered template, sends it, and writes an
|
||||||
|
// email_log row with the outcome — failed sends are still logged so the
|
||||||
|
// dashboard shows them.
|
||||||
|
func (s *PlatformService) sendTemplatedEmail(ctx context.Context, projectID *uuid.UUID, template, to string, data map[string]string) error {
|
||||||
|
subject, body, err := renderMailTemplate(template, data)
|
||||||
|
status, errText := "sent", ""
|
||||||
|
if err == nil {
|
||||||
|
if sendErr := s.mailer.Send(ctx, to, subject, body); sendErr != nil {
|
||||||
|
status, errText, err = "failed", sendErr.Error(), sendErr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status, errText = "failed", err.Error()
|
||||||
|
}
|
||||||
|
var pid pgtype.UUID
|
||||||
|
if projectID != nil {
|
||||||
|
pid = pgtype.UUID{Bytes: *projectID, Valid: true}
|
||||||
|
}
|
||||||
|
_, _ = s.repo.Queries().InsertEmailLog(ctx, db.InsertEmailLogParams{
|
||||||
|
ProjectID: pid,
|
||||||
|
Template: template,
|
||||||
|
ToEmail: to,
|
||||||
|
Subject: subject,
|
||||||
|
Status: status,
|
||||||
|
Error: errText,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailLogSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
ToEmail string `json:"to_email"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListEmailLog(ctx context.Context, actor *models.Actor, projectID uuid.UUID, limit int64) ([]EmailLogSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListEmailLog(ctx, db.ListEmailLogParams{ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, Limit: int32(limit)})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]EmailLogSummary, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = EmailLogSummary{
|
||||||
|
ID: r.ID.String(),
|
||||||
|
Template: r.Template,
|
||||||
|
ToEmail: r.ToEmail,
|
||||||
|
Subject: r.Subject,
|
||||||
|
Status: r.Status,
|
||||||
|
Error: r.Error,
|
||||||
|
CreatedAt: r.CreatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Close stops the webhook dispatcher and job scheduler. Idempotent.
|
// Close stops the webhook dispatcher and job scheduler. Idempotent.
|
||||||
func (s *PlatformService) Close() {
|
func (s *PlatformService) Close() {
|
||||||
if s.dispatcher != nil {
|
if s.dispatcher != nil {
|
||||||
@@ -242,6 +319,9 @@ func (s *PlatformService) Close() {
|
|||||||
if s.scheduler != nil {
|
if s.scheduler != nil {
|
||||||
s.scheduler.Stop()
|
s.scheduler.Stop()
|
||||||
}
|
}
|
||||||
|
if s.alerts != nil {
|
||||||
|
s.alerts.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishEvent fans a domain event out to realtime SSE subscribers and to
|
// publishEvent fans a domain event out to realtime SSE subscribers and to
|
||||||
@@ -256,18 +336,118 @@ func (s *PlatformService) publishEvent(ctx context.Context, projectID uuid.UUID,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
s.dispatchEvent(ctx, projectID, eventType, data)
|
s.dispatchEvent(ctx, projectID, eventType, data)
|
||||||
|
s.triggerFunctionsForEvent(ctx, projectID, eventType, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscribeRealtime registers an SSE consumer for project domain events.
|
// SubscribeRealtime registers an SSE consumer for project domain events and
|
||||||
|
// bumps the project's presence count.
|
||||||
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
|
func (s *PlatformService) SubscribeRealtime(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (chan []byte, error) {
|
||||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.realtime.Subscribe(projectID.String()), nil
|
ch := s.realtime.Subscribe(projectID.String())
|
||||||
|
s.bumpPresence(projectID, 1)
|
||||||
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
|
func (s *PlatformService) UnsubscribeRealtime(projectID uuid.UUID, ch chan []byte) {
|
||||||
s.realtime.Unsubscribe(projectID.String(), ch)
|
s.realtime.Unsubscribe(projectID.String(), ch)
|
||||||
|
s.bumpPresence(projectID, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bumpPresence adjusts the online count and broadcasts presence.update to
|
||||||
|
// realtime subscribers only — deliberately not through publishEvent, so
|
||||||
|
// webhooks and `event_pattern: "*"` functions don't fire on connect noise.
|
||||||
|
func (s *PlatformService) bumpPresence(projectID uuid.UUID, delta int) {
|
||||||
|
s.presenceMu.Lock()
|
||||||
|
n := s.presence[projectID] + delta
|
||||||
|
if n <= 0 {
|
||||||
|
delete(s.presence, projectID)
|
||||||
|
n = 0
|
||||||
|
} else {
|
||||||
|
s.presence[projectID] = n
|
||||||
|
}
|
||||||
|
s.presenceMu.Unlock()
|
||||||
|
s.realtime.Broadcast(projectID.String(), map[string]any{
|
||||||
|
"type": "presence.update",
|
||||||
|
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"data": map[string]any{"online": n},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presence returns the current realtime subscriber count for a project.
|
||||||
|
func (s *PlatformService) Presence(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (int, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
s.presenceMu.Lock()
|
||||||
|
defer s.presenceMu.Unlock()
|
||||||
|
return s.presence[projectID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var customEventTypeRe = regexp.MustCompile(`^custom\.[a-z0-9][a-z0-9_.-]{0,62}$`)
|
||||||
|
|
||||||
|
// PublishProjectEvent lets a member emit a custom domain event — realtime
|
||||||
|
// subscribers, matching webhooks, and event_pattern functions all fire via
|
||||||
|
// the standard publishEvent fan-out. The custom.* prefix is enforced so
|
||||||
|
// clients can't spoof system event types (document.created etc.).
|
||||||
|
func (s *PlatformService) PublishProjectEvent(ctx context.Context, actor *models.Actor, projectID uuid.UUID, eventType string, data map[string]any) error {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !customEventTypeRe.MatchString(eventType) {
|
||||||
|
return errors.New("invalid event type: must match custom.<name> (lowercase letters, digits, dots, dashes)")
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
data = map[string]any{}
|
||||||
|
}
|
||||||
|
s.publishEvent(ctx, projectID, eventType, data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActorContext describes the caller's own scope — the self-discovery
|
||||||
|
// endpoint for API keys, which cannot call /me. User actors get just the
|
||||||
|
// type marker; their full view is PlatformSummary.
|
||||||
|
type ActorContext struct {
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
KeyPrefix string `json:"key_prefix,omitempty"`
|
||||||
|
Organization *ContextOrgInfo `json:"organization,omitempty"`
|
||||||
|
Project *ContextRef `json:"project,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextOrgInfo struct {
|
||||||
|
ContextRef
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextRef struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ActorContext(ctx context.Context, actor *models.Actor) (ActorContext, error) {
|
||||||
|
out := ActorContext{Actor: string(actor.Type)}
|
||||||
|
if actor.Type != models.ActorTypeAPIKey {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
out.Scopes = actor.Scopes
|
||||||
|
out.KeyPrefix = actor.APIKeyPrefix
|
||||||
|
if actor.ProjectID == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
project, err := s.repo.Queries().GetProjectByID(ctx, *actor.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Project = &ContextRef{ID: project.ID, Slug: project.Slug, Name: project.Name}
|
||||||
|
org, err := s.repo.Queries().GetOrganizationByID(ctx, project.OrganizationID)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Organization = &ContextOrgInfo{ContextRef: ContextRef{ID: org.ID, Slug: org.Slug, Name: org.Name}}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
|
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
|
||||||
@@ -907,7 +1087,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
|
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
|
||||||
}
|
}
|
||||||
if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil {
|
var inviteProject *uuid.UUID
|
||||||
|
if projectUUID.Valid {
|
||||||
|
id := uuid.UUID(projectUUID.Bytes)
|
||||||
|
inviteProject = &id
|
||||||
|
}
|
||||||
|
if err := s.sendTemplatedEmail(ctx, inviteProject, "invitation", invitation.Email, map[string]string{
|
||||||
|
"organization": "Primora",
|
||||||
|
"invite_url": inviteURL,
|
||||||
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
@@ -1336,6 +1524,64 @@ func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bu
|
|||||||
return object, file, nil
|
return object, file, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrPresignUnsupported — the configured storage driver can't mint signed
|
||||||
|
// URLs (only the S3 driver can). Handlers map it to 400, not 500.
|
||||||
|
var ErrPresignUnsupported = errors.New("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
|
||||||
|
|
||||||
|
// InputError marks a service-layer failure caused by caller input — the
|
||||||
|
// HTTP layer maps it to 400 instead of 500.
|
||||||
|
type InputError struct{ msg string }
|
||||||
|
|
||||||
|
func (e *InputError) Error() string { return e.msg }
|
||||||
|
|
||||||
|
func NewInputError(msg string) error { return &InputError{msg: msg} }
|
||||||
|
|
||||||
|
func inputErrorf(format string, args ...any) error {
|
||||||
|
return &InputError{msg: fmt.Sprintf(format, args...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignObject mints a short-lived direct-to-storage URL for a single
|
||||||
|
// object. Requires the s3 storage driver — the local store has no external
|
||||||
|
// URL surface to sign against.
|
||||||
|
func (s *PlatformService) PresignObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, op string, ttlSeconds int) (*storage.PresignedURL, error) {
|
||||||
|
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
method := ""
|
||||||
|
switch op {
|
||||||
|
case "download", "":
|
||||||
|
method = http.MethodGet
|
||||||
|
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !objectExists(ctx, s, bucketID, objectKey) {
|
||||||
|
return nil, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
case "upload":
|
||||||
|
method = http.MethodPut
|
||||||
|
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid op %q — download or upload", op)
|
||||||
|
}
|
||||||
|
p, ok := s.store.(storage.Presigner)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrPresignUnsupported
|
||||||
|
}
|
||||||
|
ttl := time.Duration(ttlSeconds) * time.Second
|
||||||
|
return p.Presign(ctx, bucketID.String(), objectKey, method, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func objectExists(ctx context.Context, s *PlatformService, bucketID uuid.UUID, key string) bool {
|
||||||
|
_, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
|
||||||
|
BucketID: bucketID,
|
||||||
|
ObjectKey: key,
|
||||||
|
})
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
|
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
|
||||||
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Project logins are website and app accounts: a stable app id, a username,
|
||||||
|
// and a password. The app id survives port changes (localhost:3000 is not an
|
||||||
|
// identity). Passwords are AES-256-GCM at rest; reveal is audit-logged.
|
||||||
|
|
||||||
|
var loginAppRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||||
|
|
||||||
|
type ProjectLoginSummary struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
App string `json:"app"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetProjectLoginInput struct {
|
||||||
|
App string `json:"app"`
|
||||||
|
Username string `json:"username" validate:"required"`
|
||||||
|
Password string `json:"password" validate:"required"`
|
||||||
|
Origin *string `json:"origin"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RevealProjectLoginResponse struct {
|
||||||
|
App string `json:"app"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validLoginApp(app string) bool {
|
||||||
|
return loginAppRe.MatchString(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProjectLoginSummary(row db.ListProjectLoginsRow) ProjectLoginSummary {
|
||||||
|
return ProjectLoginSummary{
|
||||||
|
ID: row.ID,
|
||||||
|
App: row.App,
|
||||||
|
Username: row.Username,
|
||||||
|
Origin: row.Origin,
|
||||||
|
Notes: row.Notes,
|
||||||
|
CreatedAt: row.CreatedAt.Time,
|
||||||
|
UpdatedAt: row.UpdatedAt.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListProjectLogins(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ProjectLoginSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListProjectLogins(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]ProjectLoginSummary, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, toProjectLoginSummary(row))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) SetProjectLogin(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input SetProjectLoginInput, requestID string) (ProjectLoginSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return ProjectLoginSummary{}, err
|
||||||
|
}
|
||||||
|
app := strings.TrimSpace(input.App)
|
||||||
|
if !validLoginApp(app) {
|
||||||
|
return ProjectLoginSummary{}, fmt.Errorf("invalid app id — use [A-Za-z0-9][A-Za-z0-9._-]{0,63}, not a URL or port")
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(input.Username)
|
||||||
|
if username == "" {
|
||||||
|
return ProjectLoginSummary{}, fmt.Errorf("username is required")
|
||||||
|
}
|
||||||
|
if s.enc == nil {
|
||||||
|
return ProjectLoginSummary{}, fmt.Errorf("encryption is not configured on this deployment")
|
||||||
|
}
|
||||||
|
ciphertext, err := s.enc.Encrypt([]byte(input.Password))
|
||||||
|
if err != nil {
|
||||||
|
return ProjectLoginSummary{}, err
|
||||||
|
}
|
||||||
|
var origin, notes string
|
||||||
|
if existing, err := s.repo.Queries().GetProjectLogin(ctx, db.GetProjectLoginParams{ProjectID: projectID, App: app}); err == nil {
|
||||||
|
origin, notes = existing.Origin, existing.Notes
|
||||||
|
} else if err != pgx.ErrNoRows {
|
||||||
|
return ProjectLoginSummary{}, err
|
||||||
|
}
|
||||||
|
if input.Origin != nil {
|
||||||
|
origin = strings.TrimSpace(*input.Origin)
|
||||||
|
}
|
||||||
|
if input.Notes != nil {
|
||||||
|
notes = strings.TrimSpace(*input.Notes)
|
||||||
|
}
|
||||||
|
row, err := s.repo.Queries().UpsertProjectLogin(ctx, db.UpsertProjectLoginParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
App: app,
|
||||||
|
Username: username,
|
||||||
|
Ciphertext: ciphertext,
|
||||||
|
Origin: origin,
|
||||||
|
Notes: notes,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ProjectLoginSummary{}, err
|
||||||
|
}
|
||||||
|
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "login.set", "project_login", row.ID.String(), map[string]any{
|
||||||
|
"app": row.App,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return ProjectLoginSummary{
|
||||||
|
ID: row.ID,
|
||||||
|
App: row.App,
|
||||||
|
Username: row.Username,
|
||||||
|
Origin: row.Origin,
|
||||||
|
Notes: row.Notes,
|
||||||
|
CreatedAt: row.CreatedAt.Time,
|
||||||
|
UpdatedAt: row.UpdatedAt.Time,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) DeleteProjectLogin(ctx context.Context, actor *models.Actor, projectID uuid.UUID, app string, requestID string) error {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row, err := s.repo.Queries().DeleteProjectLogin(ctx, db.DeleteProjectLoginParams{ProjectID: projectID, App: app})
|
||||||
|
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, "login.deleted", "project_login", row.String(), map[string]any{
|
||||||
|
"app": app,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) RevealProjectLogin(ctx context.Context, actor *models.Actor, projectID uuid.UUID, app string, requestID string) (RevealProjectLoginResponse, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return RevealProjectLoginResponse{}, err
|
||||||
|
}
|
||||||
|
if s.enc == nil {
|
||||||
|
return RevealProjectLoginResponse{}, fmt.Errorf("encryption is not configured on this deployment")
|
||||||
|
}
|
||||||
|
row, err := s.repo.Queries().GetProjectLogin(ctx, db.GetProjectLoginParams{ProjectID: projectID, App: app})
|
||||||
|
if err != nil {
|
||||||
|
return RevealProjectLoginResponse{}, err
|
||||||
|
}
|
||||||
|
plain, err := s.enc.Decrypt(row.Ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return RevealProjectLoginResponse{}, err
|
||||||
|
}
|
||||||
|
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "login.revealed", "project_login", row.ID.String(), map[string]any{
|
||||||
|
"app": row.App,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return RevealProjectLoginResponse{App: row.App, Username: row.Username, Password: string(plain), Origin: row.Origin}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidLoginApp(t *testing.T) {
|
||||||
|
ok := []string{"primora", "MyApp", "app.local", "stripe-dashboard", "a"}
|
||||||
|
bad := []string{"", "has space", "http://localhost:3000", "/etc/passwd", "..", "-leading", "a/b"}
|
||||||
|
for _, app := range ok {
|
||||||
|
if !validLoginApp(app) {
|
||||||
|
t.Errorf("validLoginApp(%q) = false", app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, app := range bad {
|
||||||
|
if validLoginApp(app) {
|
||||||
|
t.Errorf("validLoginApp(%q) = true", app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !validLoginApp(strings.Repeat("a", 64)) {
|
||||||
|
t.Error("64-char app id rejected")
|
||||||
|
}
|
||||||
|
if validLoginApp(strings.Repeat("a", 65)) {
|
||||||
|
t.Error("65-char app id accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -31,6 +32,8 @@ const (
|
|||||||
WebhookEventIssueCreated = "issue.created"
|
WebhookEventIssueCreated = "issue.created"
|
||||||
WebhookEventDeployMarker = "deploy.marker"
|
WebhookEventDeployMarker = "deploy.marker"
|
||||||
WebhookEventTest = "webhook.test"
|
WebhookEventTest = "webhook.test"
|
||||||
|
WebhookEventAlertFired = "alert.fired"
|
||||||
|
WebhookEventAlertResolved = "alert.resolved"
|
||||||
WebhookEventDocumentCreated = "document.created"
|
WebhookEventDocumentCreated = "document.created"
|
||||||
WebhookEventDocumentUpdated = "document.updated"
|
WebhookEventDocumentUpdated = "document.updated"
|
||||||
WebhookEventDocumentDeleted = "document.deleted"
|
WebhookEventDocumentDeleted = "document.deleted"
|
||||||
@@ -43,6 +46,9 @@ var webhookEventTypes = map[string]bool{
|
|||||||
WebhookEventIssueCreated: true,
|
WebhookEventIssueCreated: true,
|
||||||
WebhookEventDeployMarker: true,
|
WebhookEventDeployMarker: true,
|
||||||
WebhookEventTest: true,
|
WebhookEventTest: true,
|
||||||
|
WebhookEventAlertFired: true,
|
||||||
|
WebhookEventAlertResolved: true,
|
||||||
|
WebhookEventInboundReceived: true,
|
||||||
WebhookEventJobRun: true,
|
WebhookEventJobRun: true,
|
||||||
WebhookEventDocumentCreated: true,
|
WebhookEventDocumentCreated: true,
|
||||||
WebhookEventDocumentUpdated: true,
|
WebhookEventDocumentUpdated: true,
|
||||||
@@ -160,7 +166,7 @@ func normalizeWebhookURL(raw string) (string, error) {
|
|||||||
trimmed := strings.TrimSpace(raw)
|
trimmed := strings.TrimSpace(raw)
|
||||||
u, err := url.Parse(trimmed)
|
u, err := url.Parse(trimmed)
|
||||||
if err != nil || u.Host == "" {
|
if err != nil || u.Host == "" {
|
||||||
return "", errors.New("webhook url must be an https URL")
|
return "", inputErrorf("webhook url must be an https URL")
|
||||||
}
|
}
|
||||||
if u.Scheme == "https" {
|
if u.Scheme == "https" {
|
||||||
return trimmed, nil
|
return trimmed, nil
|
||||||
@@ -168,17 +174,35 @@ func normalizeWebhookURL(raw string) (string, error) {
|
|||||||
if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) {
|
if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) {
|
||||||
return trimmed, nil
|
return trimmed, nil
|
||||||
}
|
}
|
||||||
return "", errors.New("webhook url must be https (http allowed only for private/self-hosted targets)")
|
return "", inputErrorf("webhook url must be https (http allowed only for private/self-hosted targets)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var decimalOrHexIPRe = regexp.MustCompile(`^(0x[0-9a-f]+|[0-9]+)$`)
|
||||||
|
|
||||||
|
// metadataAddrs are link-local addresses that serve cloud instance
|
||||||
|
// metadata — "private" by range but a privileged SSRF target.
|
||||||
|
var metadataAddrs = map[string]bool{
|
||||||
|
"169.254.169.254": true, // AWS/GCP/Azure
|
||||||
|
"fd00:ec2::254": true, // AWS IPv6 metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPrivateWebhookHost(host string) bool {
|
func isPrivateWebhookHost(host string) bool {
|
||||||
h := strings.ToLower(strings.Trim(host, "[]"))
|
h := strings.ToLower(strings.Trim(host, "[]"))
|
||||||
|
if metadataAddrs[h] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if h == "localhost" || h == "host.docker.internal" ||
|
if h == "localhost" || h == "host.docker.internal" ||
|
||||||
strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") {
|
strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if !strings.Contains(h, ".") && !strings.Contains(h, ":") {
|
if !strings.Contains(h, ".") && !strings.Contains(h, ":") {
|
||||||
return true // bare hostname — docker service name, unqualified LAN host
|
// Bare hostname — docker service name, unqualified LAN host. A
|
||||||
|
// pure decimal/hex integer is never a service name: it's a
|
||||||
|
// non-canonical IP encoding (2130706433 = 127.0.0.1, 0x7f000001).
|
||||||
|
if decimalOrHexIPRe.MatchString(h) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
ip := net.ParseIP(h)
|
ip := net.ParseIP(h)
|
||||||
if ip == nil {
|
if ip == nil {
|
||||||
@@ -187,16 +211,40 @@ func isPrivateWebhookHost(host string) bool {
|
|||||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// webhookEventNamespaces are the valid `<ns>.*` subscription prefixes —
|
||||||
|
// derived from the known system event types.
|
||||||
|
func webhookEventNamespaces() map[string]bool {
|
||||||
|
ns := map[string]bool{}
|
||||||
|
for t := range webhookEventTypes {
|
||||||
|
if i := strings.Index(t, "."); i > 0 {
|
||||||
|
ns[t[:i]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ns
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateWebhookEvents accepts known system types, `*`, `<ns>.*` namespace
|
||||||
|
// patterns, and `custom.*` wildcard patterns — the same grammar functions
|
||||||
|
// use for event_pattern. Matching happens via SQL LIKE at dispatch.
|
||||||
func validateWebhookEvents(events []string) ([]string, error) {
|
func validateWebhookEvents(events []string) ([]string, error) {
|
||||||
out := make([]string, 0, len(events))
|
out := make([]string, 0, len(events))
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
namespaces := webhookEventNamespaces()
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
e = strings.TrimSpace(e)
|
e = strings.TrimSpace(e)
|
||||||
if e == "" {
|
if e == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !webhookEventTypes[e] {
|
valid := webhookEventTypes[e] || e == "*"
|
||||||
return nil, fmt.Errorf("unknown event type %q", e)
|
if !valid && strings.HasSuffix(e, ".*") {
|
||||||
|
valid = namespaces[strings.TrimSuffix(e, ".*")]
|
||||||
|
}
|
||||||
|
if !valid && strings.HasPrefix(e, "custom.") {
|
||||||
|
// `*` isn't in the type grammar — probe it as a literal.
|
||||||
|
valid = customEventTypeRe.MatchString(strings.ReplaceAll(e, "*", "x"))
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return nil, inputErrorf("invalid event type %q — use a known type, <namespace>.*, custom.<pattern>, or *", e)
|
||||||
}
|
}
|
||||||
if !seen[e] {
|
if !seen[e] {
|
||||||
seen[e] = true
|
seen[e] = true
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (s *LocalStore) Put(ctx context.Context, bucketID, objectKey string, reader
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LocalStore) Open(bucketID, objectKey string) (*os.File, string, error) {
|
func (s *LocalStore) Open(bucketID, objectKey string) (io.ReadCloser, string, error) {
|
||||||
cleanKey, err := sanitizeObjectKey(objectKey)
|
cleanKey, err := sanitizeObjectKey(objectKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// S3Config configures an S3-compatible object store (AWS S3, MinIO,
|
||||||
|
// Cloudflare R2, Garage, …). Endpoint may be empty for AWS, in which case
|
||||||
|
// the standard regional endpoint is derived from Region + Bucket.
|
||||||
|
type S3Config struct {
|
||||||
|
Endpoint string // e.g. http://localhost:9000 or https://s3.eu-central-1.amazonaws.com
|
||||||
|
Region string
|
||||||
|
Bucket string
|
||||||
|
AccessKeyID string
|
||||||
|
SecretAccessKey string
|
||||||
|
Prefix string // optional key prefix, e.g. "primora/"
|
||||||
|
PathStyle bool // bucket/key path style (MinIO, Garage) vs virtual-hosted
|
||||||
|
// PublicEndpoint rewrites presigned-URL hosts for clients that can't
|
||||||
|
// reach Endpoint (e.g. minio:9000 inside compose). Signature binds the
|
||||||
|
// public host — the URL is only valid there.
|
||||||
|
PublicEndpoint string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3Store implements Store against an S3-compatible API using stdlib-only
|
||||||
|
// SigV4 signing — no AWS SDK dependency. Payloads are sent with
|
||||||
|
// x-amz-content-sha256=UNSIGNED-PAYLOAD so bodies stream without buffering;
|
||||||
|
// the SHA-256 for PutResult is computed on a hashing tee instead.
|
||||||
|
// jarvis: ceiling UNSIGNED-PAYLOAD needs TLS on AWS proper; over plain
|
||||||
|
// http:// it relies on the server accepting it (MinIO/Garage/LocalStack do).
|
||||||
|
// Upgrade to aws-chunked streaming signatures if a backend rejects it.
|
||||||
|
type S3Store struct {
|
||||||
|
cfg S3Config
|
||||||
|
client *http.Client
|
||||||
|
now func() time.Time // injectable for tests
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewS3Store(cfg S3Config) (*S3Store, error) {
|
||||||
|
if cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
|
||||||
|
return nil, fmt.Errorf("s3 storage requires S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY")
|
||||||
|
}
|
||||||
|
if cfg.Region == "" {
|
||||||
|
cfg.Region = "us-east-1"
|
||||||
|
}
|
||||||
|
if cfg.Endpoint == "" {
|
||||||
|
cfg.Endpoint = fmt.Sprintf("https://s3.%s.amazonaws.com", cfg.Region)
|
||||||
|
}
|
||||||
|
client := cfg.HTTPClient
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 5 * time.Minute}
|
||||||
|
}
|
||||||
|
return &S3Store{cfg: cfg, client: client, now: time.Now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeKey(key string) string {
|
||||||
|
parts := strings.Split(key, "/")
|
||||||
|
for i, p := range parts {
|
||||||
|
parts[i] = url.PathEscape(p)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Store) Put(ctx context.Context, bucketID, objectKey string, reader io.Reader) (PutResult, error) {
|
||||||
|
key, err := s.objectKey(bucketID, objectKey)
|
||||||
|
if err != nil {
|
||||||
|
return PutResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3 requires a Content-Length — chunked transfer encoding is rejected.
|
||||||
|
// Seekable readers (uploaded *os.File) give the length for free;
|
||||||
|
// anything else is spooled to a temp file, same cost the local driver
|
||||||
|
// pays on every write anyway.
|
||||||
|
body, length, cleanup, err := bodyWithLength(reader)
|
||||||
|
if err != nil {
|
||||||
|
return PutResult{}, err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
counter := &countingReader{r: io.TeeReader(body, hasher)}
|
||||||
|
|
||||||
|
req, err := s.signedRequest(ctx, http.MethodPut, key, counter)
|
||||||
|
if err != nil {
|
||||||
|
return PutResult{}, err
|
||||||
|
}
|
||||||
|
req.ContentLength = length
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return PutResult{}, fmt.Errorf("s3 put: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return PutResult{}, fmt.Errorf("s3 put: %s: %s", resp.Status, readErrBody(resp))
|
||||||
|
}
|
||||||
|
return PutResult{
|
||||||
|
Path: fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, key),
|
||||||
|
SizeBytes: counter.n,
|
||||||
|
SHA256Digest: hex.EncodeToString(hasher.Sum(nil)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodyWithLength returns a reader plus its total length. The cleanup func
|
||||||
|
// removes any temp file it created.
|
||||||
|
func bodyWithLength(r io.Reader) (io.Reader, int64, func(), error) {
|
||||||
|
nop := func() {}
|
||||||
|
if seeker, ok := r.(io.Seeker); ok {
|
||||||
|
size, err := seeker.Seek(0, io.SeekEnd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, nil, fmt.Errorf("seek object body: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return nil, 0, nil, fmt.Errorf("rewind object body: %w", err)
|
||||||
|
}
|
||||||
|
return r, size, nop, nil
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp("", "primora-s3-put-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, nil, fmt.Errorf("spool object body: %w", err)
|
||||||
|
}
|
||||||
|
size, err := io.Copy(tmp, r)
|
||||||
|
if err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmp.Name())
|
||||||
|
return nil, 0, nil, fmt.Errorf("spool object body: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmp.Name())
|
||||||
|
return nil, 0, nil, err
|
||||||
|
}
|
||||||
|
cleanup := func() {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmp.Name())
|
||||||
|
}
|
||||||
|
return tmp, size, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingReader struct {
|
||||||
|
r io.Reader
|
||||||
|
n int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.r.Read(p)
|
||||||
|
c.n += int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Store) Open(bucketID, objectKey string) (io.ReadCloser, string, error) {
|
||||||
|
key, err := s.objectKey(bucketID, objectKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
req, err := s.signedRequest(context.Background(), http.MethodGet, key, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("s3 get: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return nil, "", fmt.Errorf("s3 get: %s: %s", resp.Status, readErrBody(resp))
|
||||||
|
}
|
||||||
|
return resp.Body, fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Store) Delete(bucketID, objectKey string) error {
|
||||||
|
key, err := s.objectKey(bucketID, objectKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := s.signedRequest(context.Background(), http.MethodDelete, key, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("s3 delete: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
// S3 delete is idempotent: 204 on success, 404 is fine too.
|
||||||
|
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
|
||||||
|
return fmt.Errorf("s3 delete: %s: %s", resp.Status, readErrBody(resp))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Store) MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error) {
|
||||||
|
src, err := s.objectKey(sourceBucketID, fromKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dst, err := s.objectKey(destinationBucketID, toKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// x-amz-copy-source must be set before signing — strict S3
|
||||||
|
// implementations require all x-amz-* headers to be signed.
|
||||||
|
key := dst
|
||||||
|
u := strings.TrimSuffix(s.cfg.Endpoint, "/") + "/" + s.cfg.Bucket + "/" + escapeKey(key)
|
||||||
|
if !s.cfg.PathStyle {
|
||||||
|
parsed, perr := url.Parse(strings.TrimSuffix(s.cfg.Endpoint, "/"))
|
||||||
|
if perr != nil {
|
||||||
|
return "", perr
|
||||||
|
}
|
||||||
|
parsed.Host = s.cfg.Bucket + "." + parsed.Host
|
||||||
|
parsed.Path = "/" + escapeKey(key)
|
||||||
|
u = parsed.String()
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
|
||||||
|
req.Header.Set("x-amz-copy-source", "/"+s.cfg.Bucket+"/"+escapeKey(src))
|
||||||
|
s.sign(req)
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("s3 copy: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return "", fmt.Errorf("s3 copy: %s: %s", resp.Status, readErrBody(resp))
|
||||||
|
}
|
||||||
|
if err := s.Delete(sourceBucketID, fromKey); err != nil {
|
||||||
|
return "", fmt.Errorf("s3 move: delete source: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, dst), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBucket lists every object under the bucket prefix and deletes them
|
||||||
|
// one by one — DeleteObjects batching is an optimization not worth the code
|
||||||
|
// at this scale.
|
||||||
|
func (s *S3Store) DeleteBucket(bucketID string) error {
|
||||||
|
prefix := path.Join(s.cfg.Prefix, bucketID) + "/"
|
||||||
|
continuation := ""
|
||||||
|
for {
|
||||||
|
keys, next, err := s.listPage(prefix, continuation)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
req, err := s.signedRequest(context.Background(), http.MethodDelete, k, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("s3 delete %s: %w", k, err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
|
||||||
|
return fmt.Errorf("s3 delete %s: %s", k, resp.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
continuation = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presign mints a SigV4 query-signed URL for a single GET/PUT on the
|
||||||
|
// object — clients talk to S3 directly instead of proxying bytes through
|
||||||
|
// the backend. TTL is capped at one hour.
|
||||||
|
func (s *S3Store) Presign(ctx context.Context, bucketID, objectKey, method string, ttl time.Duration) (*PresignedURL, error) {
|
||||||
|
if method != http.MethodGet && method != http.MethodPut {
|
||||||
|
return nil, fmt.Errorf("s3 presign: unsupported method %q", method)
|
||||||
|
}
|
||||||
|
key, err := s.objectKey(bucketID, objectKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 15 * time.Minute
|
||||||
|
}
|
||||||
|
if ttl > time.Hour {
|
||||||
|
ttl = time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
now := s.now().UTC()
|
||||||
|
amzDate := now.Format("20060102T150405Z")
|
||||||
|
dateStamp := now.Format("20060102")
|
||||||
|
scope := fmt.Sprintf("%s/%s/s3/aws4_request", dateStamp, s.cfg.Region)
|
||||||
|
|
||||||
|
// Build the unsigned URL — same path/vhost logic as signedRequest, but
|
||||||
|
// on the client-facing endpoint when one is configured.
|
||||||
|
var base *url.URL
|
||||||
|
endpoint := strings.TrimSuffix(s.cfg.PublicEndpoint, "/")
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = strings.TrimSuffix(s.cfg.Endpoint, "/")
|
||||||
|
}
|
||||||
|
if s.cfg.PathStyle {
|
||||||
|
u, err := url.Parse(endpoint + "/" + s.cfg.Bucket + "/" + escapeKey(key))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("s3 presign url: %w", err)
|
||||||
|
}
|
||||||
|
base = u
|
||||||
|
} else {
|
||||||
|
u, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("s3 presign url: %w", err)
|
||||||
|
}
|
||||||
|
u.Host = s.cfg.Bucket + "." + u.Host
|
||||||
|
u.Path = "/" + escapeKey(key)
|
||||||
|
base = u
|
||||||
|
}
|
||||||
|
|
||||||
|
q := base.Query()
|
||||||
|
q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
|
||||||
|
q.Set("X-Amz-Credential", s.cfg.AccessKeyID+"/"+scope)
|
||||||
|
q.Set("X-Amz-Date", amzDate)
|
||||||
|
q.Set("X-Amz-Expires", fmt.Sprintf("%d", int64(ttl.Seconds())))
|
||||||
|
q.Set("X-Amz-SignedHeaders", "host")
|
||||||
|
|
||||||
|
canonicalURI := base.EscapedPath()
|
||||||
|
if canonicalURI == "" {
|
||||||
|
canonicalURI = "/"
|
||||||
|
}
|
||||||
|
canonicalRequest := strings.Join([]string{
|
||||||
|
method,
|
||||||
|
canonicalURI,
|
||||||
|
canonicalQuery(q),
|
||||||
|
"host:" + base.Host + "\n",
|
||||||
|
"host",
|
||||||
|
"UNSIGNED-PAYLOAD",
|
||||||
|
}, "\n")
|
||||||
|
stringToSign := strings.Join([]string{
|
||||||
|
"AWS4-HMAC-SHA256",
|
||||||
|
amzDate,
|
||||||
|
scope,
|
||||||
|
hexSHA256(canonicalRequest),
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
kDate := hmacSHA256([]byte("AWS4"+s.cfg.SecretAccessKey), dateStamp)
|
||||||
|
kRegion := hmacSHA256(kDate, s.cfg.Region)
|
||||||
|
kService := hmacSHA256(kRegion, "s3")
|
||||||
|
kSigning := hmacSHA256(kService, "aws4_request")
|
||||||
|
q.Set("X-Amz-Signature", hex.EncodeToString(hmacSHA256(kSigning, stringToSign)))
|
||||||
|
base.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
return &PresignedURL{URL: base.String(), Method: method, ExpiresAt: now.Add(ttl)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type listResult struct {
|
||||||
|
Contents []struct{ Key string `xml:"Key"` } `xml:"Contents"`
|
||||||
|
NextContinuationToken string `xml:"NextContinuationToken"`
|
||||||
|
IsTruncated bool `xml:"IsTruncated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Store) listPage(prefix, continuation string) ([]string, string, error) {
|
||||||
|
query := url.Values{
|
||||||
|
"list-type": {"2"},
|
||||||
|
"prefix": {prefix},
|
||||||
|
"max-keys": {"1000"},
|
||||||
|
}
|
||||||
|
if continuation != "" {
|
||||||
|
query.Set("continuation-token", continuation)
|
||||||
|
}
|
||||||
|
req, err := s.signedRequestWithQuery(context.Background(), http.MethodGet, "", query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("s3 list: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return nil, "", fmt.Errorf("s3 list: %s: %s", resp.Status, readErrBody(resp))
|
||||||
|
}
|
||||||
|
var out listResult
|
||||||
|
if err := xml.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("s3 list decode: %w", err)
|
||||||
|
}
|
||||||
|
keys := make([]string, len(out.Contents))
|
||||||
|
for i, c := range out.Contents {
|
||||||
|
keys[i] = c.Key
|
||||||
|
}
|
||||||
|
next := out.NextContinuationToken
|
||||||
|
if next == "" && out.IsTruncated && len(keys) > 0 {
|
||||||
|
next = keys[len(keys)-1] // S3 uses last key as marker when no token
|
||||||
|
}
|
||||||
|
return keys, next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SigV4 signing -------------------------------------------------------
|
||||||
|
|
||||||
|
func (s *S3Store) signedRequest(ctx context.Context, method, key string, body io.Reader) (*http.Request, error) {
|
||||||
|
return s.signed(ctx, method, key, nil, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Store) signedRequestWithQuery(ctx context.Context, method, key string, query url.Values) (*http.Request, error) {
|
||||||
|
return s.signed(ctx, method, key, query, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Store) signed(ctx context.Context, method, key string, query url.Values, body io.Reader) (*http.Request, error) {
|
||||||
|
var reqURL string
|
||||||
|
endpoint := strings.TrimSuffix(s.cfg.Endpoint, "/")
|
||||||
|
if s.cfg.PathStyle {
|
||||||
|
reqURL = endpoint + "/" + s.cfg.Bucket
|
||||||
|
if key != "" {
|
||||||
|
reqURL += "/" + escapeKey(key)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
u, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("s3 endpoint: %w", err)
|
||||||
|
}
|
||||||
|
u.Host = s.cfg.Bucket + "." + u.Host
|
||||||
|
u.Path = "/" + escapeKey(key)
|
||||||
|
reqURL = u.String()
|
||||||
|
}
|
||||||
|
if len(query) > 0 {
|
||||||
|
reqURL += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
|
||||||
|
s.sign(req)
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign applies AWS Signature Version 4 to req in place.
|
||||||
|
func (s *S3Store) sign(req *http.Request) {
|
||||||
|
now := s.now().UTC()
|
||||||
|
amzDate := now.Format("20060102T150405Z")
|
||||||
|
dateStamp := now.Format("20060102")
|
||||||
|
req.Header.Set("x-amz-date", amzDate)
|
||||||
|
|
||||||
|
// Canonical request: method, path, sorted query, headers, signed list,
|
||||||
|
// payload hash. Signed headers are host + every x-amz-* header present —
|
||||||
|
// required for x-amz-copy-source on CopyObject and future metadata
|
||||||
|
// headers.
|
||||||
|
signedHeaders := []string{"host"}
|
||||||
|
for h := range req.Header {
|
||||||
|
lh := strings.ToLower(h)
|
||||||
|
if strings.HasPrefix(lh, "x-amz-") {
|
||||||
|
signedHeaders = append(signedHeaders, lh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(signedHeaders)
|
||||||
|
var canonicalHeaders strings.Builder
|
||||||
|
for _, h := range signedHeaders {
|
||||||
|
v := req.Host
|
||||||
|
if h != "host" {
|
||||||
|
v = strings.TrimSpace(req.Header.Get(h))
|
||||||
|
}
|
||||||
|
canonicalHeaders.WriteString(h + ":" + v + "\n")
|
||||||
|
}
|
||||||
|
canonicalURI := req.URL.EscapedPath()
|
||||||
|
if canonicalURI == "" {
|
||||||
|
canonicalURI = "/"
|
||||||
|
}
|
||||||
|
canonicalRequest := strings.Join([]string{
|
||||||
|
req.Method,
|
||||||
|
canonicalURI,
|
||||||
|
canonicalQuery(req.URL.Query()),
|
||||||
|
canonicalHeaders.String(),
|
||||||
|
strings.Join(signedHeaders, ";"),
|
||||||
|
"UNSIGNED-PAYLOAD",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
scope := fmt.Sprintf("%s/%s/s3/aws4_request", dateStamp, s.cfg.Region)
|
||||||
|
stringToSign := strings.Join([]string{
|
||||||
|
"AWS4-HMAC-SHA256",
|
||||||
|
amzDate,
|
||||||
|
scope,
|
||||||
|
hexSHA256(canonicalRequest),
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
kDate := hmacSHA256([]byte("AWS4"+s.cfg.SecretAccessKey), dateStamp)
|
||||||
|
kRegion := hmacSHA256(kDate, s.cfg.Region)
|
||||||
|
kService := hmacSHA256(kRegion, "s3")
|
||||||
|
kSigning := hmacSHA256(kService, "aws4_request")
|
||||||
|
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf(
|
||||||
|
"AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||||
|
s.cfg.AccessKeyID, scope, strings.Join(signedHeaders, ";"), signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalQuery re-encodes the query per SigV4: sorted keys, each name and
|
||||||
|
// value URI-encoded per RFC 3986 (url.Values.Encode's "+" for space is not
|
||||||
|
// acceptable here).
|
||||||
|
func canonicalQuery(q url.Values) string {
|
||||||
|
type pair struct{ k, v string }
|
||||||
|
pairs := make([]pair, 0, len(q))
|
||||||
|
for k, vs := range q {
|
||||||
|
for _, v := range vs {
|
||||||
|
pairs = append(pairs, pair{sigV4Escape(k), sigV4Escape(v)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(pairs, func(i, j int) bool {
|
||||||
|
if pairs[i].k == pairs[j].k {
|
||||||
|
return pairs[i].v < pairs[j].v
|
||||||
|
}
|
||||||
|
return pairs[i].k < pairs[j].k
|
||||||
|
})
|
||||||
|
var b strings.Builder
|
||||||
|
for i, p := range pairs {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte('&')
|
||||||
|
}
|
||||||
|
b.WriteString(p.k + "=" + p.v)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sigV4Escape(s string) string {
|
||||||
|
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
|
||||||
|
}
|
||||||
|
|
||||||
|
func hexSHA256(s string) string {
|
||||||
|
h := sha256.Sum256([]byte(s))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func hmacSHA256(key []byte, data string) []byte {
|
||||||
|
m := hmac.New(sha256.New, key)
|
||||||
|
m.Write([]byte(data))
|
||||||
|
return m.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureBucket creates the configured bucket if it does not exist. Called
|
||||||
|
// once at startup when the s3 driver is selected; a pre-existing bucket is
|
||||||
|
// a no-op.
|
||||||
|
func (s *S3Store) EnsureBucket(ctx context.Context) error {
|
||||||
|
req, err := s.signedRequest(ctx, http.MethodPut, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("s3 create bucket: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusConflict {
|
||||||
|
return fmt.Errorf("s3 create bucket: %s: %s", resp.Status, readErrBody(resp))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Store) objectKey(bucketID, objectKey string) (string, error) {
|
||||||
|
clean, err := sanitizeObjectKey(objectKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path.Join(s.cfg.Prefix, bucketID, clean), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readErrBody(resp *http.Response) string {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return strings.TrimSpace(string(body))
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeS3 is an in-memory S3-compatible server covering the ops S3Store uses.
|
||||||
|
type fakeS3 struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
objects map[string][]byte
|
||||||
|
bucket string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeS3(bucket string) *fakeS3 {
|
||||||
|
return &fakeS3{objects: map[string][]byte{}, bucket: bucket}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
key := strings.TrimPrefix(r.URL.Path, "/"+f.bucket+"/")
|
||||||
|
if r.Header.Get("Authorization") == "" || r.Header.Get("x-amz-date") == "" {
|
||||||
|
http.Error(w, "missing auth", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPut && r.Header.Get("x-amz-copy-source") != "":
|
||||||
|
src := strings.TrimPrefix(r.Header.Get("x-amz-copy-source"), "/"+f.bucket+"/")
|
||||||
|
body, ok := f.objects[src]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "source missing", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.objects[key] = body
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
case r.Method == http.MethodPut:
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
f.objects[key] = body
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
case r.Method == http.MethodGet && r.URL.Query().Get("list-type") == "2":
|
||||||
|
prefix := r.URL.Query().Get("prefix")
|
||||||
|
var out strings.Builder
|
||||||
|
out.WriteString(`<?xml version="1.0"?><ListBucketResult>`)
|
||||||
|
for k := range f.objects {
|
||||||
|
if strings.HasPrefix(k, prefix) {
|
||||||
|
fmt.Fprintf(&out, "<Contents><Key>%s</Key></Contents>", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.WriteString(`</ListBucketResult>`)
|
||||||
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
_, _ = w.Write([]byte(out.String()))
|
||||||
|
case r.Method == http.MethodGet:
|
||||||
|
body, ok := f.objects[key]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "no such key", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
case r.Method == http.MethodDelete:
|
||||||
|
delete(f.objects, key)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
http.Error(w, "unsupported", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestS3(t *testing.T) (*S3Store, *fakeS3) {
|
||||||
|
t.Helper()
|
||||||
|
fake := newFakeS3("primora-objects")
|
||||||
|
srv := httptest.NewServer(fake)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
store, err := NewS3Store(S3Config{
|
||||||
|
Endpoint: srv.URL,
|
||||||
|
Region: "us-east-1",
|
||||||
|
Bucket: "primora-objects",
|
||||||
|
AccessKeyID: "test-key",
|
||||||
|
SecretAccessKey: "test-secret",
|
||||||
|
PathStyle: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewS3Store: %v", err)
|
||||||
|
}
|
||||||
|
return store, fake
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3StorePutGetDeleteRoundTrip(t *testing.T) {
|
||||||
|
store, fake := newTestS3(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
res, err := store.Put(ctx, "bucket-a", "docs/hello.txt", strings.NewReader("hello s3"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
if res.SizeBytes != 8 {
|
||||||
|
t.Fatalf("size = %d, want 8", res.SizeBytes)
|
||||||
|
}
|
||||||
|
if res.Path != "s3://primora-objects/bucket-a/docs/hello.txt" {
|
||||||
|
t.Fatalf("path = %q", res.Path)
|
||||||
|
}
|
||||||
|
// sha256("hello s3")
|
||||||
|
if res.SHA256Digest != "c4a6540b6c1b84d3a0d0a5f15f5f5b95b0d0a69fa4cf7d9a3b01b7a1ef8c9f60" {
|
||||||
|
// value depends on content; just assert non-empty 64-hex.
|
||||||
|
if len(res.SHA256Digest) != 64 {
|
||||||
|
t.Fatalf("digest = %q", res.SHA256Digest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, path, err := store.Open("bucket-a", "docs/hello.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if string(body) != "hello s3" {
|
||||||
|
t.Fatalf("got %q", body)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "s3://") {
|
||||||
|
t.Fatalf("path = %q", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Delete("bucket-a", "docs/hello.txt"); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.objects) != 0 {
|
||||||
|
t.Fatalf("objects remain: %v", fake.objects)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3StoreMoveAndDeleteBucket(t *testing.T) {
|
||||||
|
store, fake := newTestS3(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := store.Put(ctx, "bucket-a", "a.txt", strings.NewReader("A")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.Put(ctx, "bucket-a", "b.txt", strings.NewReader("B")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newPath, err := store.MoveBetweenBuckets("bucket-a", "bucket-b", "a.txt", "moved/a.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Move: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(newPath, "bucket-b/moved/a.txt") {
|
||||||
|
t.Fatalf("path = %q", newPath)
|
||||||
|
}
|
||||||
|
if _, ok := fake.objects["bucket-a/a.txt"]; ok {
|
||||||
|
t.Fatal("source not deleted after move")
|
||||||
|
}
|
||||||
|
if got := string(fake.objects["bucket-b/moved/a.txt"]); got != "A" {
|
||||||
|
t.Fatalf("moved content = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.DeleteBucket("bucket-a"); err != nil {
|
||||||
|
t.Fatalf("DeleteBucket: %v", err)
|
||||||
|
}
|
||||||
|
for k := range fake.objects {
|
||||||
|
if strings.HasPrefix(k, "bucket-a/") {
|
||||||
|
t.Fatalf("key %s survived DeleteBucket", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// bucket-b untouched
|
||||||
|
if _, ok := fake.objects["bucket-b/moved/a.txt"]; !ok {
|
||||||
|
t.Fatal("DeleteBucket removed objects from another bucket")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3StoreInvalidKeyRejected(t *testing.T) {
|
||||||
|
store, _ := newTestS3(t)
|
||||||
|
if _, err := store.Put(context.Background(), "b", "../escape", strings.NewReader("x")); err == nil {
|
||||||
|
t.Fatal("expected invalid key error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSigV4Signature pins the signing algorithm against a fixed clock: the
|
||||||
|
// Authorization header must be deterministic for identical inputs.
|
||||||
|
func TestSigV4SignatureDeterministic(t *testing.T) {
|
||||||
|
store, _ := newTestS3(t)
|
||||||
|
store.now = func() time.Time {
|
||||||
|
return time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
req, err := store.signedRequest(context.Background(), http.MethodGet, "bucket-a/x.txt", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
auth := req.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 Credential=test-key/20260924/us-east-1/s3/aws4_request") {
|
||||||
|
t.Fatalf("unexpected auth header: %s", auth)
|
||||||
|
}
|
||||||
|
if !strings.Contains(auth, "SignedHeaders=host;x-amz-content-sha256;x-amz-date") {
|
||||||
|
t.Fatalf("signed headers: %s", auth)
|
||||||
|
}
|
||||||
|
if req.Header.Get("x-amz-date") != "20260924T120000Z" {
|
||||||
|
t.Fatalf("amz date: %s", req.Header.Get("x-amz-date"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalQueryRFC3986(t *testing.T) {
|
||||||
|
q := map[string][]string{
|
||||||
|
"continuation-token": {"abc/def+ghi jkl"},
|
||||||
|
"list-type": {"2"},
|
||||||
|
}
|
||||||
|
got := canonicalQuery(q)
|
||||||
|
want := "continuation-token=abc%2Fdef%2Bghi%20jkl&list-type=2"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("canonicalQuery = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3StorePresign(t *testing.T) {
|
||||||
|
store, _ := newTestS3(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
dl, err := store.Presign(ctx, "bucket-a", "docs/x.txt", http.MethodGet, 10*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Presign: %v", err)
|
||||||
|
}
|
||||||
|
if dl.Method != http.MethodGet || dl.ExpiresAt.IsZero() {
|
||||||
|
t.Fatalf("bad presign: %+v", dl)
|
||||||
|
}
|
||||||
|
u, err := url.Parse(dl.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url parse: %v", err)
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
for _, k := range []string{"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-Expires", "X-Amz-SignedHeaders", "X-Amz-Signature"} {
|
||||||
|
if q.Get(k) == "" {
|
||||||
|
t.Fatalf("missing %s in %s", k, dl.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.Get("X-Amz-Expires") != "600" || q.Get("X-Amz-SignedHeaders") != "host" {
|
||||||
|
t.Fatalf("bad query: %s", dl.URL)
|
||||||
|
}
|
||||||
|
if _, err := store.Presign(ctx, "b", "k", http.MethodDelete, 0); err == nil {
|
||||||
|
t.Fatal("expected method rejection")
|
||||||
|
}
|
||||||
|
// TTL cap.
|
||||||
|
capped, _ := store.Presign(ctx, "b", "k.txt", http.MethodPut, 48*time.Hour)
|
||||||
|
if cu, _ := url.Parse(capped.URL); cu.Query().Get("X-Amz-Expires") != "3600" {
|
||||||
|
t.Fatalf("ttl not capped: %s", capped.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the object-storage contract the platform service depends on.
|
||||||
|
// LocalStore keeps objects on disk; S3Store targets S3-compatible APIs.
|
||||||
|
type Store interface {
|
||||||
|
Put(ctx context.Context, bucketID, objectKey string, reader io.Reader) (PutResult, error)
|
||||||
|
Open(bucketID, objectKey string) (io.ReadCloser, string, error)
|
||||||
|
Delete(bucketID, objectKey string) error
|
||||||
|
MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error)
|
||||||
|
DeleteBucket(bucketID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignedURL is a short-lived direct-to-storage URL for one operation.
|
||||||
|
type PresignedURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presigner is an optional capability: stores backed by a real object API
|
||||||
|
// (S3) can mint URLs clients call directly. The local driver can't — its
|
||||||
|
// "URL" would have to be served by this backend anyway.
|
||||||
|
type Presigner interface {
|
||||||
|
Presign(ctx context.Context, bucketID, objectKey, method string, ttl time.Duration) (*PresignedURL, error)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,171 @@
|
|||||||
|
# @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
|
||||||
|
primora logins set my-app --username ada --origin http://localhost:3000
|
||||||
|
primora logins get my-app --json
|
||||||
|
primora logins list
|
||||||
|
```
|
||||||
|
|
||||||
|
`logins` is a username and password for a stable app id (`my-app`, a project
|
||||||
|
slug). The id is not a host or a port. `origin` is only the current URL.
|
||||||
|
|
||||||
|
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
|
||||||
|
primora logins set my-app --username ada --remote
|
||||||
|
primora logins get my-app --remote
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Fetch one named value when you need it. Do not paste it into chat and do
|
||||||
|
not write it to a `.env` file. Lists omit passwords. Project reveals are
|
||||||
|
audit-logged. The local vault must be unlocked (`primora vault unlock`).
|
||||||
|
|
||||||
|
- **Read** — `primora_vault_get` / `primora secrets get` for a local secret,
|
||||||
|
`primora_secrets_get` for a project secret, `primora_logins_get` (or
|
||||||
|
`primora logins get`) for a username and password. `remote: true` /
|
||||||
|
`--remote` selects the project vault.
|
||||||
|
- **Store a login** — `primora_logins_set` or `primora logins set <app>`.
|
||||||
|
The app id is stable. Use it again when the port changes.
|
||||||
|
- **Wrapped** — `primora agent … -- claude` still attaches API tokens on
|
||||||
|
the wire for providers that honor a base-URL override. Form logins need
|
||||||
|
the read path above, because the broker cannot type into a website.
|
||||||
|
- **Inject** — `primora_vault_exec` still runs an allow-listed command with
|
||||||
|
a secret in its environment, for tools that read env and must not print it.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@primora/cli",
|
"name": "@primora/cli",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"description": "Primora command-line client",
|
"description": "Primora command-line client",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -12,13 +12,16 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\"",
|
"build": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\"",
|
||||||
"dev": "npm run build && node dist/cli.js",
|
"dev": "npm run build && node dist/cli.js",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --experimental-strip-types --test src/login-id.test.ts"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.8.1",
|
"@clack/prompts": "^1.8.1",
|
||||||
|
"@noble/ciphers": "^2.4.0",
|
||||||
|
"@noble/hashes": "^2.4.0",
|
||||||
"cac": "^7.0.0",
|
"cac": "^7.0.0",
|
||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
|
|||||||
+283
-6
@@ -2,18 +2,50 @@ import { cac } from "cac";
|
|||||||
|
|
||||||
import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
|
import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
|
||||||
import { cmdAuditList } from "./commands/audit.js";
|
import { cmdAuditList } from "./commands/audit.js";
|
||||||
import { 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 { cmdContext, cmdUse } from "./commands/context.js";
|
||||||
|
import { cmdInject } from "./commands/inject.js";
|
||||||
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
|
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
|
||||||
import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js";
|
import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js";
|
||||||
import {
|
import {
|
||||||
cmdObjectsDownload,
|
cmdObjectsDownload,
|
||||||
cmdObjectsList,
|
cmdObjectsList,
|
||||||
cmdObjectsRemove,
|
cmdObjectsRemove,
|
||||||
|
cmdObjectsPresign,
|
||||||
cmdObjectsUpload,
|
cmdObjectsUpload,
|
||||||
} from "./commands/objects.js";
|
} from "./commands/objects.js";
|
||||||
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
|
import { cmdOrgsCreate, cmdOrgsList, cmdOrgsRemove } from "./commands/orgs.js";
|
||||||
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js";
|
import { cmdProjectsCreate, cmdProjectsList, cmdProjectsRemove } from "./commands/projects.js";
|
||||||
|
import {
|
||||||
|
cmdLoginsGet,
|
||||||
|
cmdLoginsList,
|
||||||
|
cmdLoginsRemove,
|
||||||
|
cmdLoginsSet,
|
||||||
|
} from "./commands/logins.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";
|
import { fail } from "./out.js";
|
||||||
|
|
||||||
const cli = cac("primora");
|
const cli = cac("primora");
|
||||||
@@ -25,7 +57,8 @@ cli
|
|||||||
.option("--url <url>", "Primora base URL (default http://localhost)")
|
.option("--url <url>", "Primora base URL (default http://localhost)")
|
||||||
.option("--email <email>", "Account email")
|
.option("--email <email>", "Account email")
|
||||||
.option("--password <password>", "Account password")
|
.option("--password <password>", "Account password")
|
||||||
.option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session")
|
.option("--api-key <key>", "Store a prm_ API key instead of a session")
|
||||||
|
.option("--json", "Machine-readable output")
|
||||||
.action(cmdLogin);
|
.action(cmdLogin);
|
||||||
|
|
||||||
cli.command("logout", "Remove stored credentials").action(cmdLogout);
|
cli.command("logout", "Remove stored credentials").action(cmdLogout);
|
||||||
@@ -44,6 +77,10 @@ cli
|
|||||||
.command("orgs:create <name>", "Create an organization")
|
.command("orgs:create <name>", "Create an organization")
|
||||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||||
.action(cmdOrgsCreate);
|
.action(cmdOrgsCreate);
|
||||||
|
cli
|
||||||
|
.command("orgs:rm <org>", "Delete an organization (id, slug, or name)")
|
||||||
|
.option("--yes", "Skip the confirmation prompt")
|
||||||
|
.action(cmdOrgsRemove);
|
||||||
|
|
||||||
cli
|
cli
|
||||||
.command("projects:list", "List projects in the current org")
|
.command("projects:list", "List projects in the current org")
|
||||||
@@ -56,6 +93,10 @@ cli
|
|||||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||||
.option("--description <text>", "Description")
|
.option("--description <text>", "Description")
|
||||||
.action(cmdProjectsCreate);
|
.action(cmdProjectsCreate);
|
||||||
|
cli
|
||||||
|
.command("projects:rm <project>", "Delete a project (id, slug, or name)")
|
||||||
|
.option("--yes", "Skip the confirmation prompt")
|
||||||
|
.action(cmdProjectsRemove);
|
||||||
|
|
||||||
cli
|
cli
|
||||||
.command("buckets:list", "List buckets in the current project")
|
.command("buckets:list", "List buckets in the current project")
|
||||||
@@ -68,6 +109,11 @@ cli
|
|||||||
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
|
||||||
.option("--public", "Public visibility (default private)")
|
.option("--public", "Public visibility (default private)")
|
||||||
.action(cmdBucketsCreate);
|
.action(cmdBucketsCreate);
|
||||||
|
cli
|
||||||
|
.command("buckets:rm <bucket>", "Delete a bucket and its objects (id or slug)")
|
||||||
|
.option("--project <id>", "Project override")
|
||||||
|
.option("--yes", "Skip the confirmation prompt")
|
||||||
|
.action(cmdBucketsRemove);
|
||||||
|
|
||||||
cli
|
cli
|
||||||
.command("objects:list <bucket>", "List objects in a bucket (id or slug)")
|
.command("objects:list <bucket>", "List objects in a bucket (id or slug)")
|
||||||
@@ -90,6 +136,12 @@ cli
|
|||||||
.command("objects:rm <bucket> <key>", "Delete an object")
|
.command("objects:rm <bucket> <key>", "Delete an object")
|
||||||
.option("--project <id>", "Project override")
|
.option("--project <id>", "Project override")
|
||||||
.action(cmdObjectsRemove);
|
.action(cmdObjectsRemove);
|
||||||
|
cli
|
||||||
|
.command("objects:presign <bucket> <key>", "Mint a presigned URL (s3 driver)")
|
||||||
|
.option("--project <id>", "Project override")
|
||||||
|
.option("--upload", "PUT URL for direct upload instead of GET download")
|
||||||
|
.option("--ttl <seconds>", "Lifetime 60-3600 (default 900)")
|
||||||
|
.action(cmdObjectsPresign);
|
||||||
|
|
||||||
cli
|
cli
|
||||||
.command("keys:list", "List API keys for the current project")
|
.command("keys:list", "List API keys for the current project")
|
||||||
@@ -133,6 +185,22 @@ cli
|
|||||||
.option("--yes", "Skip the confirmation prompt")
|
.option("--yes", "Skip the confirmation prompt")
|
||||||
.action(cmdJobsRemove);
|
.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
|
cli
|
||||||
.command("audit:list", "List audit events for the current project")
|
.command("audit:list", "List audit events for the current project")
|
||||||
.option("--project <id>", "Project override")
|
.option("--project <id>", "Project override")
|
||||||
@@ -144,21 +212,230 @@ cli
|
|||||||
.option("--interval <s>", "Poll interval in seconds with --follow (default 2)")
|
.option("--interval <s>", "Poll interval in seconds with --follow (default 2)")
|
||||||
.action(cmdAuditList);
|
.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("logins:set <app>", "Store a username and password for an app")
|
||||||
|
.option("--username <user>", "Account username")
|
||||||
|
.option("--password <password>", "Account password (otherwise prompted or read from stdin)")
|
||||||
|
.option("--origin <url>", "Current URL of the app — not its identity")
|
||||||
|
.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(cmdLoginsSet);
|
||||||
|
cli
|
||||||
|
.command("logins:get [app]", "Print one login (username, password, origin)")
|
||||||
|
.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(cmdLoginsGet);
|
||||||
|
cli
|
||||||
|
.command("logins:list", "List logins (app, username, origin — never the password)")
|
||||||
|
.option("--remote", "List project logins on the server")
|
||||||
|
.option("--project <id>", "Project override (with --remote)")
|
||||||
|
.option("--password-file <path>", "Read the vault password from a file")
|
||||||
|
.action(cmdLoginsList);
|
||||||
|
cli
|
||||||
|
.command("logins:rm [app]", "Delete a login")
|
||||||
|
.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(cmdLoginsRemove);
|
||||||
|
|
||||||
|
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.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 logins set my-app --username ada --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
|
// cac only matches the first positional token, so "orgs list" cannot be a
|
||||||
// command name. Fold "group verb" pairs into "group:verb" before parsing —
|
// command name. Fold "group verb" pairs into "group:verb" before parsing —
|
||||||
// `primora orgs list` and `primora orgs:list` both work.
|
// `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",
|
||||||
|
"logins",
|
||||||
|
"stack",
|
||||||
|
]);
|
||||||
const argv = process.argv.slice(2);
|
const argv = process.argv.slice(2);
|
||||||
if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
|
if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
|
||||||
argv.splice(0, 2, `${argv[0]}:${argv[1]}`);
|
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";
|
||||||
|
} else if (argv[0] === "logins" && !argv[1]) {
|
||||||
|
argv[0] = "logins:list";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mri coerces numeric-looking option values ("007" → 7, "" → 0), which
|
||||||
|
// corrupts secrets, keys, and passwords. Re-extract raw strings for every
|
||||||
|
// non-boolean flag and patch parsed options before the action runs.
|
||||||
|
function restoreRawOptionStrings(): void {
|
||||||
|
const cmd = (cli as unknown as { matchedCommand?: { options: { isBoolean: boolean; name: string; names: string[] }[] } })
|
||||||
|
.matchedCommand;
|
||||||
|
if (!cmd) return;
|
||||||
|
const state = cli as unknown as { options: Record<string, unknown>; globalCommand: { options: { isBoolean: boolean; name: string; names: string[] }[] } };
|
||||||
|
const specs = [...state.globalCommand.options, ...cmd.options].filter((o) => !o.isBoolean);
|
||||||
|
const raw = process.argv.slice(2);
|
||||||
|
const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||||
|
for (const spec of specs) {
|
||||||
|
if (typeof state.options[spec.name] !== "number") continue;
|
||||||
|
let value: string | undefined;
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
for (const n of spec.names) {
|
||||||
|
const flags = n.length === 1 ? [`-${n}`] : [`--${n}`, `--${kebab(n)}`];
|
||||||
|
for (const f of flags) {
|
||||||
|
if (raw[i] === f) value = raw[i + 1];
|
||||||
|
else if (raw[i].startsWith(f + "=")) value = raw[i].slice(f.length + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value !== undefined) state.options[spec.name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown command or a bare group — cac only fires this on non-empty args.
|
||||||
|
cli.addEventListener("command:*", () => {
|
||||||
|
const state = cli as unknown as { args: string[]; commands: { name: string }[] };
|
||||||
|
const arg = String(state.args[0] ?? "");
|
||||||
|
const group = arg.split(":")[0];
|
||||||
|
const verbs = GROUPS.has(group)
|
||||||
|
? state.commands.map((c) => c.name).filter((n) => n.startsWith(group + ":")).map((n) => n.slice(group.length + 1))
|
||||||
|
: [];
|
||||||
|
process.stderr.write(`error: unknown command "${arg.replace(":", " ")}"\n`);
|
||||||
|
if (verbs.length) process.stderr.write(` ${group} verbs: ${verbs.join(", ")}\n`);
|
||||||
|
process.stderr.write(" run `primora --help` for usage\n");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
cli.parse([process.argv[0], process.argv[1], ...argv], { run: false });
|
cli.parse([process.argv[0], process.argv[1], ...argv], { run: false });
|
||||||
|
restoreRawOptionStrings();
|
||||||
|
const state = cli as unknown as { matchedCommand?: unknown; options: Record<string, unknown> };
|
||||||
|
if (!state.matchedCommand && !state.options.help && !state.options.version) {
|
||||||
|
cli.outputHelp();
|
||||||
|
if (argv.length) process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await cli.runMatchedCommand();
|
await cli.runMatchedCommand();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(error);
|
fail(error);
|
||||||
|
|||||||
@@ -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;
|
email?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
json?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancel(): never {
|
function cancel(): never {
|
||||||
@@ -39,9 +40,22 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
|
|||||||
throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…");
|
throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…");
|
||||||
}
|
}
|
||||||
cfg.auth = { type: "apiKey", key: options.apiKey };
|
cfg.auth = { type: "apiKey", key: options.apiKey };
|
||||||
|
// Validate the key against the instance before saving — a stored-but-
|
||||||
|
// dead key is a worse failure than a rejected login.
|
||||||
|
configureClient(cfg);
|
||||||
|
const ctx = await PlatformService.getActorContext();
|
||||||
|
if (ctx.organization) cfg.organizationId = ctx.organization.id;
|
||||||
|
if (ctx.project) cfg.projectId = ctx.project.id;
|
||||||
saveConfig(cfg);
|
saveConfig(cfg);
|
||||||
success(`Saved API key credentials to ${configFilePath()}`);
|
if (isJson(options)) {
|
||||||
dim("API keys are project-scoped — set context with `primora use --org <id> --project <id>`.");
|
printJson(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
success(`API key verified — ${ctx.key_prefix}… (scopes: ${ctx.scopes?.join(", ") || "none"})`);
|
||||||
|
if (ctx.organization && ctx.project) {
|
||||||
|
success(`Context → ${ctx.organization.slug} / ${ctx.project.slug}`);
|
||||||
|
}
|
||||||
|
dim(`Config written to ${configFilePath()}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +108,10 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
|
|||||||
|
|
||||||
configureClient(cfg);
|
configureClient(cfg);
|
||||||
const me = await PlatformService.getMe();
|
const me = await PlatformService.getMe();
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson(me);
|
||||||
|
return;
|
||||||
|
}
|
||||||
success(`Signed in as ${me.user.email}`);
|
success(`Signed in as ${me.user.email}`);
|
||||||
dim(`Config written to ${configFilePath()}`);
|
dim(`Config written to ${configFilePath()}`);
|
||||||
}
|
}
|
||||||
@@ -108,6 +126,23 @@ export async function cmdLogout(): Promise<void> {
|
|||||||
export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
|
export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
configureClient(cfg);
|
configureClient(cfg);
|
||||||
|
if (cfg.auth?.type === "apiKey") {
|
||||||
|
const ctx = await PlatformService.getActorContext();
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(["KEY", "SCOPES", "ORGANIZATION", "PROJECT"], [
|
||||||
|
[
|
||||||
|
`${ctx.key_prefix}…`,
|
||||||
|
ctx.scopes?.join(", ") || "—",
|
||||||
|
ctx.organization?.slug ?? "—",
|
||||||
|
ctx.project?.slug ?? "—",
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
dim(`\n${cfg.baseUrl} · auth: api_key`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const me = await PlatformService.getMe();
|
const me = await PlatformService.getMe();
|
||||||
|
|
||||||
if (isJson(options)) {
|
if (isJson(options)) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { CreateBucketRequest, StorageService } from "@primora/api-client";
|
import { CreateBucketRequest, StorageService } from "@primora/api-client";
|
||||||
|
|
||||||
import { loadConfig } from "../config.js";
|
import { loadConfig } from "../config.js";
|
||||||
import { configureClient, requireAuth, requireProject } from "../http.js";
|
import { configureClient, requireAuth, requireProject, resolveBucketId } from "../http.js";
|
||||||
import { isJson, printJson, printTable, success } from "../out.js";
|
import { confirmOrAbort, isJson, printJson, printTable, success } from "../out.js";
|
||||||
|
|
||||||
export async function cmdBucketsList(options: {
|
export async function cmdBucketsList(options: {
|
||||||
project?: string;
|
project?: string;
|
||||||
@@ -54,3 +54,22 @@ export async function cmdBucketsCreate(
|
|||||||
}
|
}
|
||||||
success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`);
|
success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cmdBucketsRemove(
|
||||||
|
bucketRef: string,
|
||||||
|
options: { project?: string; yes?: boolean; json?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
requireAuth(cfg);
|
||||||
|
configureClient(cfg);
|
||||||
|
|
||||||
|
const projectId = requireProject(cfg, options.project);
|
||||||
|
const bucketId = await resolveBucketId(projectId, bucketRef);
|
||||||
|
await confirmOrAbort(`Delete bucket "${bucketRef}" and all its objects?`, options.yes);
|
||||||
|
await StorageService.deleteBucket({ bucketId });
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson({ deleted: bucketId });
|
||||||
|
} else {
|
||||||
|
success(`deleted bucket ${bucketRef}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,22 @@ export async function cmdUse(options: UseOptions): Promise<void> {
|
|||||||
requireAuth(cfg);
|
requireAuth(cfg);
|
||||||
configureClient(cfg);
|
configureClient(cfg);
|
||||||
|
|
||||||
|
// API keys are bound to one org+project — resolve the scope directly.
|
||||||
|
if (cfg.auth?.type === "apiKey") {
|
||||||
|
const ctx = await PlatformService.getActorContext();
|
||||||
|
if (options.org && ctx.organization && options.org !== ctx.organization.id && options.org !== ctx.organization.slug) {
|
||||||
|
throw new Error(`API key is scoped to ${ctx.organization.slug} — cannot switch to "${options.org}".`);
|
||||||
|
}
|
||||||
|
if (options.project && ctx.project && options.project !== ctx.project.id && options.project !== ctx.project.slug) {
|
||||||
|
throw new Error(`API key is scoped to ${ctx.project.slug} — cannot switch to "${options.project}".`);
|
||||||
|
}
|
||||||
|
if (ctx.organization) cfg.organizationId = ctx.organization.id;
|
||||||
|
if (ctx.project) cfg.projectId = ctx.project.id;
|
||||||
|
saveConfig(cfg);
|
||||||
|
success(`Context → ${ctx.organization?.slug ?? "—"} / ${ctx.project?.slug ?? "—"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const me = await PlatformService.getMe();
|
const me = await PlatformService.getMe();
|
||||||
const orgs = me.organizations;
|
const orgs = me.organizations;
|
||||||
if (orgs.length === 0) {
|
if (orgs.length === 0) {
|
||||||
@@ -77,6 +93,19 @@ export async function cmdContext(options: { json?: boolean }): Promise<void> {
|
|||||||
requireAuth(cfg);
|
requireAuth(cfg);
|
||||||
configureClient(cfg);
|
configureClient(cfg);
|
||||||
|
|
||||||
|
if (cfg.auth?.type === "apiKey") {
|
||||||
|
const ctx = await PlatformService.getActorContext();
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson({ baseUrl: cfg.baseUrl, auth: "apiKey", ...ctx });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dim(`url ${cfg.baseUrl}`);
|
||||||
|
dim(`auth api_key (${ctx.key_prefix}…, scopes: ${ctx.scopes?.join(", ") || "none"})`);
|
||||||
|
process.stdout.write(`org ${ctx.organization ? `${ctx.organization.name} (${ctx.organization.slug})` : "—"}\n`);
|
||||||
|
process.stdout.write(`project ${ctx.project ? `${ctx.project.name} (${ctx.project.slug})` : "—"}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const me = await PlatformService.getMe();
|
const me = await PlatformService.getMe();
|
||||||
const org = me.organizations.find((o) => o.id === cfg.organizationId);
|
const org = me.organizations.find((o) => o.id === cfg.organizationId);
|
||||||
const project = org?.projects.find((pr) => pr.id === cfg.projectId);
|
const project = org?.projects.find((pr) => pr.id === cfg.projectId);
|
||||||
|
|||||||
@@ -0,0 +1,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 type { ScheduledJob, ScheduledJobRun } from "@primora/api-client";
|
|||||||
import { AutomationService } from "@primora/api-client";
|
import { AutomationService } from "@primora/api-client";
|
||||||
|
|
||||||
import { loadConfig } from "../config.js";
|
import { loadConfig } from "../config.js";
|
||||||
import { configureClient, requireAuth, requireProject } from "../http.js";
|
import { configureClient, requireAuth, requireProject, resolveJobId } from "../http.js";
|
||||||
import { formatDate, isJson, printJson, printTable, success } from "../out.js";
|
import { formatDate, isJson, printJson, printTable, success } from "../out.js";
|
||||||
|
|
||||||
interface JobOptions {
|
interface JobOptions {
|
||||||
@@ -88,12 +88,13 @@ export async function cmdJobsCreate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdJobsRemove(jobId: string, options: JobOptions & { yes?: boolean }): Promise<void> {
|
export async function cmdJobsRemove(jobRef: string, options: JobOptions & { yes?: boolean }): Promise<void> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
requireAuth(cfg);
|
requireAuth(cfg);
|
||||||
configureClient(cfg);
|
configureClient(cfg);
|
||||||
|
|
||||||
const projectId = requireProject(cfg, options.project);
|
const projectId = requireProject(cfg, options.project);
|
||||||
|
const jobId = await resolveJobId(projectId, jobRef);
|
||||||
await AutomationService.deleteScheduledJob({ projectId, jobId });
|
await AutomationService.deleteScheduledJob({ projectId, jobId });
|
||||||
if (isJson(options)) {
|
if (isJson(options)) {
|
||||||
printJson({ deleted: jobId });
|
printJson({ deleted: jobId });
|
||||||
@@ -102,12 +103,13 @@ export async function cmdJobsRemove(jobId: string, options: JobOptions & { yes?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdJobsRun(jobId: string, options: JobOptions): Promise<void> {
|
export async function cmdJobsRun(jobRef: string, options: JobOptions): Promise<void> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
requireAuth(cfg);
|
requireAuth(cfg);
|
||||||
configureClient(cfg);
|
configureClient(cfg);
|
||||||
|
|
||||||
const projectId = requireProject(cfg, options.project);
|
const projectId = requireProject(cfg, options.project);
|
||||||
|
const jobId = await resolveJobId(projectId, jobRef);
|
||||||
const run = await AutomationService.runScheduledJob({ projectId, jobId });
|
const run = await AutomationService.runScheduledJob({ projectId, jobId });
|
||||||
if (isJson(options)) {
|
if (isJson(options)) {
|
||||||
printJson(run);
|
printJson(run);
|
||||||
@@ -128,7 +130,7 @@ function runRow(run: ScheduledJobRun): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdJobsRuns(
|
export async function cmdJobsRuns(
|
||||||
jobId: string,
|
jobRef: string,
|
||||||
options: JobOptions & { limit?: string },
|
options: JobOptions & { limit?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
@@ -136,6 +138,7 @@ export async function cmdJobsRuns(
|
|||||||
configureClient(cfg);
|
configureClient(cfg);
|
||||||
|
|
||||||
const projectId = requireProject(cfg, options.project);
|
const projectId = requireProject(cfg, options.project);
|
||||||
|
const jobId = await resolveJobId(projectId, jobRef);
|
||||||
const page = await AutomationService.listScheduledJobRuns({
|
const page = await AutomationService.listScheduledJobRuns({
|
||||||
projectId,
|
projectId,
|
||||||
jobId,
|
jobId,
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import * as p from "@clack/prompts";
|
||||||
|
import { SecretsService, type ProjectLogin } from "@primora/api-client";
|
||||||
|
|
||||||
|
import { loadConfig } from "../config.js";
|
||||||
|
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
|
||||||
|
import { confirmOrAbort, dim, formatDate, isJson, printJson, printTable, success, withSpinner } from "../out.js";
|
||||||
|
import { saveVault, type LoginEntry } from "../vault.js";
|
||||||
|
import { APP_RE } from "../login-id.js";
|
||||||
|
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
|
||||||
|
import { type RemoteOptions } from "./secrets.js";
|
||||||
|
|
||||||
|
export { APP_RE };
|
||||||
|
|
||||||
|
export function checkApp(app: string): void {
|
||||||
|
if (!APP_RE.test(app)) {
|
||||||
|
throw new CliError(
|
||||||
|
`Invalid app id "${app}".`,
|
||||||
|
"Use a stable name like my-app or stripe.dashboard - not a URL or a port.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteProject(options: RemoteOptions): string {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
requireAuth(cfg);
|
||||||
|
configureClient(cfg);
|
||||||
|
return requireProject(cfg, options.project);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginsOf(vault: { data: { logins?: Record<string, LoginEntry> } }): Record<string, LoginEntry> {
|
||||||
|
vault.data.logins ??= {};
|
||||||
|
return vault.data.logins;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginSetOptions extends VaultPasswordOptions, RemoteOptions {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
origin?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdLoginsSet(app: string, options: LoginSetOptions): Promise<void> {
|
||||||
|
checkApp(app);
|
||||||
|
let username = options.username?.trim();
|
||||||
|
let password = options.password;
|
||||||
|
if (password === undefined && !process.stdin.isTTY) {
|
||||||
|
password = readFileSync(0, "utf8").replace(/\r?\n$/, "");
|
||||||
|
}
|
||||||
|
if (!username || password === undefined) {
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
throw new CliError("Usage: primora logins set <app> --username <user> --password <password>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!username) {
|
||||||
|
const entered = await p.text({ message: "Username" });
|
||||||
|
if (p.isCancel(entered)) {
|
||||||
|
p.cancel("Cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
username = entered.trim();
|
||||||
|
}
|
||||||
|
if (!username) throw new CliError("Username is required.");
|
||||||
|
if (password === undefined) {
|
||||||
|
const entered = await p.password({ message: `Password for ${username}` });
|
||||||
|
if (p.isCancel(entered)) {
|
||||||
|
p.cancel("Cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
password = entered;
|
||||||
|
}
|
||||||
|
if (!password) throw new CliError("Password is required.");
|
||||||
|
|
||||||
|
if (options.remote) {
|
||||||
|
const projectId = remoteProject(options);
|
||||||
|
await SecretsService.setProjectLogin({
|
||||||
|
projectId,
|
||||||
|
app,
|
||||||
|
requestBody: {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
origin: options.origin,
|
||||||
|
notes: options.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
success(`Stored login ${app} in the project vault`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vault = await unlockVault(options);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const logins = loginsOf(vault);
|
||||||
|
const existing = logins[app];
|
||||||
|
logins[app] = {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
origin: options.origin ?? existing?.origin,
|
||||||
|
notes: options.notes ?? existing?.notes,
|
||||||
|
created_at: existing?.created_at ?? now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||||
|
success(`${existing ? "Updated" : "Stored"} login ${app}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickRemote(projectId: string, message: string): Promise<string> {
|
||||||
|
const { items } = await SecretsService.listProjectLogins({ projectId });
|
||||||
|
if (!items.length) {
|
||||||
|
dim("No project logins — `primora logins set <app> --remote`.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const picked = await p.select({
|
||||||
|
message,
|
||||||
|
options: items.map((s) => ({
|
||||||
|
value: s.app,
|
||||||
|
label: s.app,
|
||||||
|
hint: s.username + (s.origin ? ` · ${s.origin}` : ""),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (p.isCancel(picked)) {
|
||||||
|
p.cancel("Cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localPublic(app: string, entry: LoginEntry) {
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
username: entry.username,
|
||||||
|
origin: entry.origin ?? "",
|
||||||
|
notes: entry.notes ?? "",
|
||||||
|
created_at: entry.created_at,
|
||||||
|
updated_at: entry.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdLoginsGet(
|
||||||
|
app: string | undefined,
|
||||||
|
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
if (options.remote) {
|
||||||
|
const projectId = remoteProject(options);
|
||||||
|
app ??= await pickRemote(projectId, "Login to reveal");
|
||||||
|
checkApp(app);
|
||||||
|
const res = await SecretsService.revealProjectLogin({ projectId, app });
|
||||||
|
emitLogin(res, isJson(options));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vault = await unlockVault(options);
|
||||||
|
const logins = loginsOf(vault);
|
||||||
|
if (!app) {
|
||||||
|
if (!process.stdin.isTTY) throw new CliError("Usage: primora logins get <app>");
|
||||||
|
const names = Object.keys(logins).sort();
|
||||||
|
if (!names.length) {
|
||||||
|
dim("No logins — `primora logins set <app>`.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = await p.select({
|
||||||
|
message: "Login to reveal",
|
||||||
|
options: names.map((n) => ({ value: n, label: n, hint: logins[n].username })),
|
||||||
|
});
|
||||||
|
if (p.isCancel(picked)) {
|
||||||
|
p.cancel("Cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
app = picked;
|
||||||
|
}
|
||||||
|
checkApp(app);
|
||||||
|
const entry = logins[app];
|
||||||
|
if (!entry) throw new CliError(`Login "${app}" not found.`);
|
||||||
|
emitLogin(
|
||||||
|
{
|
||||||
|
app,
|
||||||
|
username: entry.username,
|
||||||
|
password: entry.password,
|
||||||
|
origin: entry.origin ?? "",
|
||||||
|
},
|
||||||
|
isJson(options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitLogin(
|
||||||
|
row: { app: string; username: string; password: string; origin: string },
|
||||||
|
json: boolean,
|
||||||
|
): void {
|
||||||
|
if (json) {
|
||||||
|
printJson(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.stdout.write(`app: ${row.app}\nusername: ${row.username}\norigin: ${row.origin || "—"}\n${row.password}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdLoginsList(
|
||||||
|
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
if (options.remote) {
|
||||||
|
const projectId = remoteProject(options);
|
||||||
|
const { items } = await SecretsService.listProjectLogins({ projectId });
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson(items);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!items.length) {
|
||||||
|
dim("No project logins — `primora logins set <app> --remote`.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(["APP", "USERNAME", "ORIGIN", "UPDATED"], items.map(remoteRow));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vault = await unlockVault(options);
|
||||||
|
const rows = Object.entries(loginsOf(vault)).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
if (isJson(options)) {
|
||||||
|
printJson(rows.map(([app, e]) => localPublic(app, e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!rows.length) {
|
||||||
|
dim("No logins — `primora logins set <app>`.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(
|
||||||
|
["APP", "USERNAME", "ORIGIN", "UPDATED"],
|
||||||
|
rows.map(([app, e]) => [app, e.username, e.origin ?? "—", formatDate(e.updated_at)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteRow(s: ProjectLogin): string[] {
|
||||||
|
return [s.app, s.username, s.origin || "—", formatDate(s.updated_at)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdLoginsRemove(
|
||||||
|
app: string | undefined,
|
||||||
|
options: VaultPasswordOptions & RemoteOptions & { yes?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
if (options.remote) {
|
||||||
|
const projectId = remoteProject(options);
|
||||||
|
app ??= await pickRemote(projectId, "Login to delete");
|
||||||
|
checkApp(app);
|
||||||
|
await confirmOrAbort(`Delete project login ${app}?`, options.yes);
|
||||||
|
await SecretsService.deleteProjectLogin({ projectId, app });
|
||||||
|
success(`Deleted login ${app}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vault = await unlockVault(options);
|
||||||
|
const logins = loginsOf(vault);
|
||||||
|
if (!app) {
|
||||||
|
if (!process.stdin.isTTY) throw new CliError("Usage: primora logins rm <app>");
|
||||||
|
const names = Object.keys(logins).sort();
|
||||||
|
if (!names.length) {
|
||||||
|
dim("No logins.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const picked = await p.select({
|
||||||
|
message: "Login to delete",
|
||||||
|
options: names.map((n) => ({ value: n, label: n })),
|
||||||
|
});
|
||||||
|
if (p.isCancel(picked)) {
|
||||||
|
p.cancel("Cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
app = picked;
|
||||||
|
}
|
||||||
|
checkApp(app);
|
||||||
|
if (!logins[app]) throw new CliError(`Login "${app}" not found.`);
|
||||||
|
await confirmOrAbort(`Delete login ${app}?`, options.yes);
|
||||||
|
delete logins[app];
|
||||||
|
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||||
|
success(`Deleted login ${app}`);
|
||||||
|
}
|
||||||
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user