From 69b65dc4dff2bb1aa6671cb88cde33b30aee0ff3 Mon Sep 17 00:00:00 2001 From: kalevski Date: Wed, 8 Jul 2026 21:47:14 +0200 Subject: [PATCH 1/3] voxscribe app implemented --- .github/workflows/nginxpilot.yml | 4 +- .github/workflows/quaykeeper.yml | 4 +- .github/workflows/taskforge.yml | 4 +- .github/workflows/voxscribe.yml | 68 + examples/public/web-components/SKILL.md | 6 +- nginxpilot/Dockerfile | 2 +- package-lock.json | 77 +- package.json | 1 + quaykeeper/Dockerfile | 2 +- taskforge/Dockerfile | 2 +- taskforge/server/domain/site-settings.ts | 22 +- taskforge/test/site-settings.test.ts | 15 +- voxscribe/.dockerignore | 11 + voxscribe/.env.example | 51 + voxscribe/.gitignore | 29 + voxscribe/Dockerfile | 100 + voxscribe/app/admin/audit/page.tsx | 18 + voxscribe/app/admin/models/page.tsx | 18 + voxscribe/app/admin/settings/page.tsx | 18 + voxscribe/app/admin/users/page.tsx | 18 + voxscribe/app/api/admin/settings/route.ts | 34 + voxscribe/app/api/audit/route.ts | 22 + .../app/api/auth/github/callback/route.ts | 64 + voxscribe/app/api/auth/github/route.ts | 12 + voxscribe/app/api/auth/logout/route.ts | 20 + voxscribe/app/api/health/route.ts | 10 + voxscribe/app/api/me/route.ts | 27 + .../app/api/models/[name]/download/route.ts | 23 + voxscribe/app/api/models/[name]/route.ts | 22 + voxscribe/app/api/models/route.ts | 20 + .../app/api/notes/[id]/download/route.ts | 40 + voxscribe/app/api/notes/[id]/route.ts | 67 + voxscribe/app/api/notes/route.ts | 58 + voxscribe/app/api/settings/route.ts | 14 + voxscribe/app/api/stats/route.ts | 13 + voxscribe/app/api/tags/route.ts | 16 + .../api/transcriptions/[id]/audio/route.ts | 82 + .../api/transcriptions/[id]/retry/route.ts | 28 + .../app/api/transcriptions/[id]/route.ts | 73 + .../transcriptions/[id]/transcript/route.ts | 48 + .../app/api/transcriptions/events/route.ts | 16 + voxscribe/app/api/transcriptions/route.ts | 121 + voxscribe/app/api/users/[id]/route.ts | 27 + voxscribe/app/api/users/route.ts | 13 + voxscribe/app/globals.css | 2024 +++++++++++++++++ voxscribe/app/layout.tsx | 42 + voxscribe/app/login/page.tsx | 18 + voxscribe/app/new/page.tsx | 18 + voxscribe/app/no-access/page.tsx | 15 + voxscribe/app/not-found.tsx | 19 + voxscribe/app/notes/[id]/page.tsx | 19 + voxscribe/app/notes/new/page.tsx | 18 + voxscribe/app/notes/page.tsx | 18 + voxscribe/app/page.tsx | 15 + voxscribe/app/providers.tsx | 36 + voxscribe/app/transcriptions/[id]/page.tsx | 19 + voxscribe/app/transcriptions/page.tsx | 18 + voxscribe/components/AppShell.tsx | 106 + voxscribe/components/CommandPalette.tsx | 66 + voxscribe/components/ConfirmDialog.tsx | 51 + voxscribe/components/DashboardClient.tsx | 131 ++ voxscribe/components/DataTable.tsx | 89 + voxscribe/components/FormModal.tsx | 103 + voxscribe/components/LoginClient.tsx | 59 + voxscribe/components/NoAccessClient.tsx | 22 + voxscribe/components/Toast.tsx | 64 + voxscribe/components/TypeToConfirmModal.tsx | 53 + voxscribe/components/admin/AuditClient.tsx | 77 + voxscribe/components/admin/ModelsClient.tsx | 140 ++ voxscribe/components/admin/SettingsClient.tsx | 192 ++ voxscribe/components/admin/UsersClient.tsx | 105 + voxscribe/components/fields.tsx | 456 ++++ .../components/note/NoteEditorClient.tsx | 201 ++ voxscribe/components/note/NotesClient.tsx | 273 +++ voxscribe/components/states.tsx | 115 + .../components/transcription/DetailClient.tsx | 255 +++ .../components/transcription/DownloadMenu.tsx | 25 + .../transcription/LibraryClient.tsx | 259 +++ .../transcription/PlayerWithTranscript.tsx | 63 + .../components/transcription/StatusChip.tsx | 38 + .../components/transcription/UploadClient.tsx | 317 +++ voxscribe/docker-entrypoint.sh | 29 + voxscribe/instrumentation.ts | 28 + voxscribe/middleware.ts | 78 + voxscribe/next.config.mjs | 10 + voxscribe/package.json | 36 + voxscribe/run-docker.sh | 78 + voxscribe/server/config.ts | 117 + voxscribe/server/data/db.ts | 300 +++ .../server/data/repositories/audit-repo.ts | 53 + .../server/data/repositories/note-repo.ts | 208 ++ .../server/data/repositories/setting-repo.ts | 33 + .../server/data/repositories/tag-repo.ts | 48 + .../data/repositories/transcription-repo.ts | 347 +++ .../server/data/repositories/user-repo.ts | 70 + voxscribe/server/domain/format.ts | 83 + voxscribe/server/domain/note-validation.ts | 115 + voxscribe/server/domain/quota.ts | 24 + voxscribe/server/domain/settings.ts | 197 ++ voxscribe/server/domain/transcription.ts | 42 + voxscribe/server/domain/types.ts | 179 ++ voxscribe/server/domain/upload-validation.ts | 114 + voxscribe/server/infrastructure/ffmpeg.ts | 114 + voxscribe/server/infrastructure/fs-media.ts | 96 + voxscribe/server/infrastructure/ids.ts | 21 + .../server/infrastructure/model-store.ts | 163 ++ voxscribe/server/infrastructure/server-log.ts | 17 + voxscribe/server/infrastructure/whisper.ts | 130 ++ voxscribe/server/services/auth.ts | 290 +++ voxscribe/server/services/models.ts | 67 + voxscribe/server/services/notes.ts | 200 ++ voxscribe/server/services/settings.ts | 67 + voxscribe/server/services/stats.ts | 38 + voxscribe/server/services/sweep.ts | 99 + voxscribe/server/services/transcriptions.ts | 373 +++ voxscribe/server/services/users.ts | 41 + voxscribe/server/services/worker.ts | 165 ++ voxscribe/server/web/http.ts | 53 + voxscribe/server/web/page-guards.ts | 35 + voxscribe/server/web/sse.ts | 97 + voxscribe/tsconfig.json | 27 + voxscribe/types/lucide-static.d.ts | 4 + 122 files changed, 11147 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/voxscribe.yml create mode 100644 voxscribe/.dockerignore create mode 100644 voxscribe/.env.example create mode 100644 voxscribe/.gitignore create mode 100644 voxscribe/Dockerfile create mode 100644 voxscribe/app/admin/audit/page.tsx create mode 100644 voxscribe/app/admin/models/page.tsx create mode 100644 voxscribe/app/admin/settings/page.tsx create mode 100644 voxscribe/app/admin/users/page.tsx create mode 100644 voxscribe/app/api/admin/settings/route.ts create mode 100644 voxscribe/app/api/audit/route.ts create mode 100644 voxscribe/app/api/auth/github/callback/route.ts create mode 100644 voxscribe/app/api/auth/github/route.ts create mode 100644 voxscribe/app/api/auth/logout/route.ts create mode 100644 voxscribe/app/api/health/route.ts create mode 100644 voxscribe/app/api/me/route.ts create mode 100644 voxscribe/app/api/models/[name]/download/route.ts create mode 100644 voxscribe/app/api/models/[name]/route.ts create mode 100644 voxscribe/app/api/models/route.ts create mode 100644 voxscribe/app/api/notes/[id]/download/route.ts create mode 100644 voxscribe/app/api/notes/[id]/route.ts create mode 100644 voxscribe/app/api/notes/route.ts create mode 100644 voxscribe/app/api/settings/route.ts create mode 100644 voxscribe/app/api/stats/route.ts create mode 100644 voxscribe/app/api/tags/route.ts create mode 100644 voxscribe/app/api/transcriptions/[id]/audio/route.ts create mode 100644 voxscribe/app/api/transcriptions/[id]/retry/route.ts create mode 100644 voxscribe/app/api/transcriptions/[id]/route.ts create mode 100644 voxscribe/app/api/transcriptions/[id]/transcript/route.ts create mode 100644 voxscribe/app/api/transcriptions/events/route.ts create mode 100644 voxscribe/app/api/transcriptions/route.ts create mode 100644 voxscribe/app/api/users/[id]/route.ts create mode 100644 voxscribe/app/api/users/route.ts create mode 100644 voxscribe/app/globals.css create mode 100644 voxscribe/app/layout.tsx create mode 100644 voxscribe/app/login/page.tsx create mode 100644 voxscribe/app/new/page.tsx create mode 100644 voxscribe/app/no-access/page.tsx create mode 100644 voxscribe/app/not-found.tsx create mode 100644 voxscribe/app/notes/[id]/page.tsx create mode 100644 voxscribe/app/notes/new/page.tsx create mode 100644 voxscribe/app/notes/page.tsx create mode 100644 voxscribe/app/page.tsx create mode 100644 voxscribe/app/providers.tsx create mode 100644 voxscribe/app/transcriptions/[id]/page.tsx create mode 100644 voxscribe/app/transcriptions/page.tsx create mode 100644 voxscribe/components/AppShell.tsx create mode 100644 voxscribe/components/CommandPalette.tsx create mode 100644 voxscribe/components/ConfirmDialog.tsx create mode 100644 voxscribe/components/DashboardClient.tsx create mode 100644 voxscribe/components/DataTable.tsx create mode 100644 voxscribe/components/FormModal.tsx create mode 100644 voxscribe/components/LoginClient.tsx create mode 100644 voxscribe/components/NoAccessClient.tsx create mode 100644 voxscribe/components/Toast.tsx create mode 100644 voxscribe/components/TypeToConfirmModal.tsx create mode 100644 voxscribe/components/admin/AuditClient.tsx create mode 100644 voxscribe/components/admin/ModelsClient.tsx create mode 100644 voxscribe/components/admin/SettingsClient.tsx create mode 100644 voxscribe/components/admin/UsersClient.tsx create mode 100644 voxscribe/components/fields.tsx create mode 100644 voxscribe/components/note/NoteEditorClient.tsx create mode 100644 voxscribe/components/note/NotesClient.tsx create mode 100644 voxscribe/components/states.tsx create mode 100644 voxscribe/components/transcription/DetailClient.tsx create mode 100644 voxscribe/components/transcription/DownloadMenu.tsx create mode 100644 voxscribe/components/transcription/LibraryClient.tsx create mode 100644 voxscribe/components/transcription/PlayerWithTranscript.tsx create mode 100644 voxscribe/components/transcription/StatusChip.tsx create mode 100644 voxscribe/components/transcription/UploadClient.tsx create mode 100644 voxscribe/docker-entrypoint.sh create mode 100644 voxscribe/instrumentation.ts create mode 100644 voxscribe/middleware.ts create mode 100644 voxscribe/next.config.mjs create mode 100644 voxscribe/package.json create mode 100755 voxscribe/run-docker.sh create mode 100644 voxscribe/server/config.ts create mode 100644 voxscribe/server/data/db.ts create mode 100644 voxscribe/server/data/repositories/audit-repo.ts create mode 100644 voxscribe/server/data/repositories/note-repo.ts create mode 100644 voxscribe/server/data/repositories/setting-repo.ts create mode 100644 voxscribe/server/data/repositories/tag-repo.ts create mode 100644 voxscribe/server/data/repositories/transcription-repo.ts create mode 100644 voxscribe/server/data/repositories/user-repo.ts create mode 100644 voxscribe/server/domain/format.ts create mode 100644 voxscribe/server/domain/note-validation.ts create mode 100644 voxscribe/server/domain/quota.ts create mode 100644 voxscribe/server/domain/settings.ts create mode 100644 voxscribe/server/domain/transcription.ts create mode 100644 voxscribe/server/domain/types.ts create mode 100644 voxscribe/server/domain/upload-validation.ts create mode 100644 voxscribe/server/infrastructure/ffmpeg.ts create mode 100644 voxscribe/server/infrastructure/fs-media.ts create mode 100644 voxscribe/server/infrastructure/ids.ts create mode 100644 voxscribe/server/infrastructure/model-store.ts create mode 100644 voxscribe/server/infrastructure/server-log.ts create mode 100644 voxscribe/server/infrastructure/whisper.ts create mode 100644 voxscribe/server/services/auth.ts create mode 100644 voxscribe/server/services/models.ts create mode 100644 voxscribe/server/services/notes.ts create mode 100644 voxscribe/server/services/settings.ts create mode 100644 voxscribe/server/services/stats.ts create mode 100644 voxscribe/server/services/sweep.ts create mode 100644 voxscribe/server/services/transcriptions.ts create mode 100644 voxscribe/server/services/users.ts create mode 100644 voxscribe/server/services/worker.ts create mode 100644 voxscribe/server/web/http.ts create mode 100644 voxscribe/server/web/page-guards.ts create mode 100644 voxscribe/server/web/sse.ts create mode 100644 voxscribe/tsconfig.json create mode 100644 voxscribe/types/lucide-static.d.ts diff --git a/.github/workflows/nginxpilot.yml b/.github/workflows/nginxpilot.yml index b416cd06..0028bb95 100644 --- a/.github/workflows/nginxpilot.yml +++ b/.github/workflows/nginxpilot.yml @@ -40,8 +40,6 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - name: Log in to GHCR @@ -65,7 +63,7 @@ jobs: uses: docker/build-push-action@v6 with: context: nginxpilot - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 push: ${{ github.event_name == 'push' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/quaykeeper.yml b/.github/workflows/quaykeeper.yml index c681b446..329ede63 100644 --- a/.github/workflows/quaykeeper.yml +++ b/.github/workflows/quaykeeper.yml @@ -34,8 +34,6 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - name: Log in to GHCR @@ -62,7 +60,7 @@ jobs: # siblings, so the build needs the whole monorepo (see quaykeeper/Dockerfile). context: . file: quaykeeper/Dockerfile - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 push: ${{ github.event_name == 'push' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/taskforge.yml b/.github/workflows/taskforge.yml index 098bbeac..a9e1710f 100644 --- a/.github/workflows/taskforge.yml +++ b/.github/workflows/taskforge.yml @@ -34,8 +34,6 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - name: Log in to GHCR @@ -62,7 +60,7 @@ jobs: # siblings, so the build needs the whole monorepo (see taskforge/Dockerfile). context: . file: taskforge/Dockerfile - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 push: ${{ github.event_name == 'push' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/voxscribe.yml b/.github/workflows/voxscribe.yml new file mode 100644 index 00000000..f1c40653 --- /dev/null +++ b/.github/workflows/voxscribe.yml @@ -0,0 +1,68 @@ +name: voxscribe + +on: + push: + branches: [main] + # the image bundles built @toolcase/base + web-components, so changes there + # must rebuild voxscribe too (build context is the repo root). + paths: + - 'voxscribe/**' + - 'base/**' + - 'web-components/**' + - 'package-lock.json' + - '.dockerignore' + - '.github/workflows/voxscribe.yml' + pull_request: + paths: + - 'voxscribe/**' + - 'base/**' + - 'web-components/**' + - 'package-lock.json' + - '.dockerignore' + - '.github/workflows/voxscribe.yml' + +permissions: + contents: read + packages: write + +env: + IMAGE_NAME: ghcr.io/${{ github.repository }}/voxscribe + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,format=short + + - name: Build and push + uses: docker/build-push-action@v6 + with: + # repo root: voxscribe's @toolcase/* deps are workspace siblings, so + # the build needs the whole monorepo (see voxscribe/Dockerfile). + context: . + file: voxscribe/Dockerfile + platforms: linux/amd64 + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=voxscribe + cache-to: type=gha,scope=voxscribe,mode=max diff --git a/examples/public/web-components/SKILL.md b/examples/public/web-components/SKILL.md index 148b8771..58b119a6 100644 --- a/examples/public/web-components/SKILL.md +++ b/examples/public/web-components/SKILL.md @@ -21161,7 +21161,7 @@ Tag input with autocomplete recommendations and optional create-on-type. A form- | Event | Detail | Description | |-------|--------|-------------| -| `tc-change` | `{ tags: string[] }` | Dispatched whenever a tag is added or removed. | +| `tc-change` | `{ value: string[] }` | Dispatched whenever a tag is added or removed; `detail.value` is the updated tags array (canonical field-change shape). | **Slots:** none. @@ -21190,7 +21190,7 @@ Tag input with autocomplete recommendations and optional create-on-type. A form- ti.recommendations = ['TypeScript', 'JavaScript', 'Python', 'Rust', 'Go'] ti.defaultValue = ['TypeScript'] ti.addEventListener('tc-change', e => { - console.log('tags:', e.detail.tags) + console.log('tags:', e.detail.value) }) @@ -21208,7 +21208,7 @@ Tag input with autocomplete recommendations and optional create-on-type. A form- ctl.recommendations = ['Go', 'Rust', 'Zig'] ctl.value = ['Go'] ctl.addEventListener('tc-change', e => { - ctl.value = e.detail.tags // parent owns the state + ctl.value = e.detail.value // parent owns the state }) diff --git a/nginxpilot/Dockerfile b/nginxpilot/Dockerfile index 3571a1f5..47f75ff9 100644 --- a/nginxpilot/Dockerfile +++ b/nginxpilot/Dockerfile @@ -38,7 +38,7 @@ ARG CERTBOT_DNS_PLUGINS="certbot-dns-digitalocean certbot-dns-cloudflare certbot # every pip invocation crashes on `import pyexpat`. Naming libexpat with # --upgrade is what actually moves it forward. RUN apk add --no-cache --upgrade libexpat git openssh-client ca-certificates \ - tzdata su-exec libcap certbot py3-pip && \ + tzdata su-exec libcap certbot py3-pip curl && \ pip install --break-system-packages certbot-nginx ${CERTBOT_DNS_PLUGINS} RUN addgroup -S nginxpilot 2>/dev/null; \ diff --git a/package-lock.json b/package-lock.json index f86ffe4e..bd01d773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "taskforge", "quaykeeper", "quaykeeper-landing", + "voxscribe", "examples" ], "dependencies": { @@ -2514,6 +2515,10 @@ "resolved": "taskforge", "link": true }, + "node_modules/@toolcase/voxscribe": { + "resolved": "voxscribe", + "link": true + }, "node_modules/@toolcase/web-components": { "resolved": "web-components", "link": true @@ -2583,6 +2588,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/busboy": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz", + "integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3880,6 +3895,17 @@ "esbuild": ">=0.18" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -8449,6 +8475,14 @@ "node": ">= 0.4" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -10640,9 +10674,50 @@ "node": ">=14.17" } }, + "voxscribe": { + "name": "@toolcase/voxscribe", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@toolcase/base": "^5.0.0", + "@toolcase/web-components": "^5.0.7", + "busboy": "^1.6.0", + "lucide-static": "^1.22.0", + "next": "^16.2.9", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "server-only": "^0.0.1" + }, + "devDependencies": { + "@types/busboy": "^1.5.4", + "@types/node": "^22.5", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^10.1.0", + "eslint-config-next": "^16.2.10", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=22.5" + } + }, + "voxscribe/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "web-components": { "name": "@toolcase/web-components", - "version": "5.0.8", + "version": "5.0.9", "license": "MIT", "dependencies": { "@popperjs/core": "^2.11.8", diff --git a/package.json b/package.json index 66b1f7ac..e47dd6ee 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "taskforge", "quaykeeper", "quaykeeper-landing", + "voxscribe", "examples" ], "devDependencies": { diff --git a/quaykeeper/Dockerfile b/quaykeeper/Dockerfile index 6250404e..58f87c65 100644 --- a/quaykeeper/Dockerfile +++ b/quaykeeper/Dockerfile @@ -41,7 +41,7 @@ ENV QUAYKEEPER_AGENT_PORT=4101 ENV QUAYKEEPER_CLIENT_DIR=/app/quaykeeper/client-bin RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates \ + && apt-get install -y --no-install-recommends ca-certificates curl \ && rm -rf /var/lib/apt/lists/* # non-root user diff --git a/taskforge/Dockerfile b/taskforge/Dockerfile index 7579b173..6b29beb8 100644 --- a/taskforge/Dockerfile +++ b/taskforge/Dockerfile @@ -40,7 +40,7 @@ ENV APP_SKILLS_DIR=/app/skills # git + openssh-client (git operations / SSH remotes) + the Claude Code CLI # (default agent backend) RUN apt-get update \ - && apt-get install -y --no-install-recommends git openssh-client ca-certificates \ + && apt-get install -y --no-install-recommends git openssh-client ca-certificates wget curl \ && rm -rf /var/lib/apt/lists/* \ && npm i -g @anthropic-ai/claude-code \ && npm cache clean --force diff --git a/taskforge/server/domain/site-settings.ts b/taskforge/server/domain/site-settings.ts index fd054c5e..5fa922bd 100644 --- a/taskforge/server/domain/site-settings.ts +++ b/taskforge/server/domain/site-settings.ts @@ -36,7 +36,20 @@ export const THEME_LABEL: Record = { * selectors are double-attribute scoped, so the `default` theme needs an explicit * `data-tc-theme="default"` when a variant is active (the branding context handles it). */ -export const VARIANT_NAMES = ['', 'ocean', 'forest', 'ember', 'royal'] as const +export const VARIANT_NAMES = [ + '', + 'ocean', + 'forest', + 'ember', + 'royal', + 'mint', + 'rose', + 'crimson', + 'indigo', + 'slate', + 'sunset', + 'twilight', +] as const export type VariantName = (typeof VARIANT_NAMES)[number] @@ -47,6 +60,13 @@ export const VARIANT_LABEL: Record = { forest: 'Forest (green / lime)', ember: 'Ember (orange / gold)', royal: 'Royal (violet / magenta)', + mint: 'Mint (teal / mint)', + rose: 'Rose (rose / pink)', + crimson: 'Crimson (red / coral)', + indigo: 'Indigo (indigo / periwinkle)', + slate: 'Slate (steel / silver)', + sunset: 'Sunset (coral → orange)', + twilight: 'Twilight (violet → blue)', } /** Type guard: a request-supplied value is one of the bundled theme names. */ diff --git a/taskforge/test/site-settings.test.ts b/taskforge/test/site-settings.test.ts index ea670b11..374ec9e0 100644 --- a/taskforge/test/site-settings.test.ts +++ b/taskforge/test/site-settings.test.ts @@ -27,7 +27,20 @@ describe('isThemeName (tracks the bundled web-components skins)', () => { describe('isVariantName (tracks the bundled accent variants)', () => { it('accepts every variant including the base-accents empty string', () => { for (const v of VARIANT_NAMES) expect(isVariantName(v)).toBe(true) - expect(VARIANT_NAMES).toEqual(['', 'ocean', 'forest', 'ember', 'royal']) + expect(VARIANT_NAMES).toEqual([ + '', + 'ocean', + 'forest', + 'ember', + 'royal', + 'mint', + 'rose', + 'crimson', + 'indigo', + 'slate', + 'sunset', + 'twilight', + ]) expect(isVariantName('pastel')).toBe(false) expect(isVariantName(42)).toBe(false) }) diff --git a/voxscribe/.dockerignore b/voxscribe/.dockerignore new file mode 100644 index 00000000..6a24bd64 --- /dev/null +++ b/voxscribe/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +.git +.env +.env*.local +npm-debug.log* +README.md +.workspace +# Local SQLite store — must never enter the image. +voxscribe.db +voxscribe.db-* diff --git a/voxscribe/.env.example b/voxscribe/.env.example new file mode 100644 index 00000000..b015f6dd --- /dev/null +++ b/voxscribe/.env.example @@ -0,0 +1,51 @@ +# voxscribe environment — cp .env.example .env.local (dev) or .env (docker) and fill in. + +# ── GitHub OAuth (required) ─────────────────────────────────────────────────── +# Create an OAuth app at https://github.com/settings/developers with callback +# /api/auth/github/callback +VOXSCRIBE_GITHUB_CLIENT_ID= +VOXSCRIBE_GITHUB_CLIENT_SECRET= +VOXSCRIBE_OAUTH_REDIRECT_URI=http://localhost:4200/api/auth/github/callback + +# Cookie HMAC secret — at least 32 chars, e.g. `openssl rand -hex 32` (required). +VOXSCRIBE_AUTH_SECRET= + +# Session lifetime in seconds (default 86400 = 1 day). +#VOXSCRIBE_SESSION_TTL=86400 + +# ── optional sign-in gate (empty = open to any GitHub account) ──────────────── +# CSV of GitHub logins allowed to sign in; users passing OAuth but not listed +# land as guest → /no-access. +#VOXSCRIBE_GITHUB_ALLOWED_LOGINS= +# Or require membership in a GitHub org. +#VOXSCRIBE_GITHUB_ALLOWED_ORG= + +# ── engine ──────────────────────────────────────────────────────────────────── +# whisper.cpp CLI binary (default: `whisper-cli` on PATH). +#VOXSCRIBE_WHISPER_BIN=whisper-cli +# Where ggml model blobs live (default ${WORKSPACE_DIR}/models). +#VOXSCRIBE_MODEL_DIR= +# Default per-job model (must be in the allow-list below). +#VOXSCRIBE_DEFAULT_MODEL=small +# Model allow-list. `large` is rejected by validation regardless (RAM budget). +#VOXSCRIBE_ALLOWED_MODELS=tiny,base,small +# whisper -t thread count (default max(1, cpus-1)). +#VOXSCRIBE_THREADS= + +# ── limits ──────────────────────────────────────────────────────────────────── +#VOXSCRIBE_MAX_UPLOAD_MB=500 +#VOXSCRIBE_MAX_DURATION_MIN=240 +#VOXSCRIBE_JOB_TIMEOUT_MIN=120 +# Per-user media quota in MB (admins exempt). +#VOXSCRIBE_USER_QUOTA_MB=2048 + +# ── shared infra ────────────────────────────────────────────────────────────── +# Durable state root: SQLite DB, models/, media/, notes/ live under it. +#WORKSPACE_DIR=/workspace +# SQLite file override (default ${WORKSPACE_DIR}/voxscribe.db). +#DB_PATH= +#PORT=4200 + +# Bare-metal operator note: after a hard crash, check for orphaned `whisper-cli` +# processes before restarting (`pgrep -f whisper-cli`) — a detached child can +# outlive the server. Under Docker a container restart kills every process in it. diff --git a/voxscribe/.gitignore b/voxscribe/.gitignore new file mode 100644 index 00000000..abc4d439 --- /dev/null +++ b/voxscribe/.gitignore @@ -0,0 +1,29 @@ +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* + +# local env files +.env +.env*.local + +# local dev workspace (SQLite store, media, models) +/.workspace/ +voxscribe.db +voxscribe.db-* + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/voxscribe/Dockerfile b/voxscribe/Dockerfile new file mode 100644 index 00000000..b8f49112 --- /dev/null +++ b/voxscribe/Dockerfile @@ -0,0 +1,100 @@ +# syntax=docker/dockerfile:1 +# +# voxscribe ships as ONE image (spec §11): +# 1. compile whisper.cpp FIRST (portable binary, -DGGML_NATIVE=OFF) — the +# transcription engine, spawned as a child process at runtime. +# 2. fetch the seed ggml model — the default 'small' tier, baked so the image +# transcribes out of the box. +# 3. build the Next.js app — voxscribe is an npm workspace whose @toolcase/* +# deps are sibling workspaces, so the build needs the WHOLE monorepo: build +# context is the repo root and every COPY path below is repo-root-relative. +# Node >= 22.5 for the built-in node:sqlite. +# 4. assemble a slim runner LAST with ffmpeg + whisper-cli alongside the app. +# +# Only the seed model is baked; larger tiers still live in the mounted +# ${WORKSPACE_DIR}/models volume, fetched via the admin model manager. Because +# that volume SHADOWS the image's model dir at runtime, the seed blob is baked +# to /opt/voxscribe/seed-models and copied into the volume on start by +# docker-entrypoint.sh (idempotent — operator downloads stay durable). + +# ── Stage 1 — whisper.cpp: compile the engine FIRST ──────────────────────────── +FROM debian:bookworm-slim AS whisper +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential cmake git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +ARG WHISPER_CPP_VERSION=v1.7.5 +RUN git clone --depth 1 --branch ${WHISPER_CPP_VERSION} https://github.com/ggml-org/whisper.cpp /src +WORKDIR /src +# -DGGML_NATIVE=OFF → portable binary (no -march=native), runs on any x86-64/arm64. +RUN cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF -DBUILD_SHARED_LIBS=OFF \ + && cmake --build build --config Release -j "$(nproc)" --target whisper-cli \ + && install -m 0755 build/bin/whisper-cli /usr/local/bin/whisper-cli + +# ── Stage 2 — seed model: bake the default 'small' ggml blob ───────────────────── +FROM debian:bookworm-slim AS model +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* +# Which tier to bake. Must match server/config.ts defaultModel to work out-of-box. +ARG SEED_MODEL=small +RUN mkdir -p /seed-models \ + && curl -fSL --retry 3 \ + -o "/seed-models/ggml-${SEED_MODEL}.bin" \ + "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${SEED_MODEL}.bin" \ + && test -s "/seed-models/ggml-${SEED_MODEL}.bin" + +# ── Stage 3 — Next.js builder ─────────────────────────────────────────────────── +FROM node:22-slim AS builder +WORKDIR /repo + +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm ci \ + && npm run build -w @toolcase/base -w @toolcase/web-components -w @toolcase/voxscribe + +# ── Stage 4 — runner ───────────────────────────────────────────────────────────── +FROM node:22-slim AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=4200 + +# ffmpeg/ffprobe for decode validation + 16 kHz mono transcode; curl for HEALTHCHECK. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates ffmpeg curl \ + && rm -rf /var/lib/apt/lists/* + +# non-root user +RUN useradd -m -u 10001 app + +# the engine from stage 1 +COPY --from=whisper /usr/local/bin/whisper-cli /usr/local/bin/whisper-cli + +# the seed model from stage 2 — copied into the volume at start by the entrypoint +# (a+rX so any runtime uid can read it before copying). +COPY --from=model /seed-models /opt/voxscribe/seed-models +RUN chmod -R a+rX /opt/voxscribe/seed-models + +# start-time model seeder (idempotent; survives the ${WORKSPACE_DIR} volume mount) +COPY voxscribe/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod 0755 /usr/local/bin/docker-entrypoint.sh + +# Next standalone tree nests the app under ./voxscribe — mirror it exactly. +COPY --from=builder --chown=app:app /repo/voxscribe/.next/standalone ./ +COPY --from=builder --chown=app:app /repo/voxscribe/.next/static ./voxscribe/.next/static +COPY --from=builder --chown=app:app /repo/voxscribe/public ./voxscribe/public + +# Next writes its incremental cache here at runtime; run-docker.sh may run the +# container as an arbitrary host uid, so the dir must be writable by any uid. +RUN mkdir -p /app/voxscribe/.next/cache && chmod -R 0777 /app/voxscribe/.next/cache + +# Durable state (SQLite + media + models + notes) under WORKSPACE_DIR=/workspace. +RUN mkdir -p /workspace && chown app:app /workspace + +USER app +EXPOSE 4200 +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \ + CMD curl -fsS http://127.0.0.1:4200/api/health || exit 1 +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["node", "voxscribe/server.js"] diff --git a/voxscribe/app/admin/audit/page.tsx b/voxscribe/app/admin/audit/page.tsx new file mode 100644 index 00000000..ef1db9f2 --- /dev/null +++ b/voxscribe/app/admin/audit/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' +import { requireRole } from '@/server/web/page-guards' +import { AppShell } from '@/components/AppShell' +import { AuditClient } from '@/components/admin/AuditClient' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { title: 'Audit' } + +export default async function AdminAuditPage() { + const me = await requireRole('admin') + return ( + + + + ) +} diff --git a/voxscribe/app/admin/models/page.tsx b/voxscribe/app/admin/models/page.tsx new file mode 100644 index 00000000..29caacbb --- /dev/null +++ b/voxscribe/app/admin/models/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' +import { requireRole } from '@/server/web/page-guards' +import { AppShell } from '@/components/AppShell' +import { ModelsClient } from '@/components/admin/ModelsClient' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { title: 'Models' } + +export default async function AdminModelsPage() { + const me = await requireRole('admin') + return ( + + + + ) +} diff --git a/voxscribe/app/admin/settings/page.tsx b/voxscribe/app/admin/settings/page.tsx new file mode 100644 index 00000000..6caf737e --- /dev/null +++ b/voxscribe/app/admin/settings/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' +import { requireRole } from '@/server/web/page-guards' +import { AppShell } from '@/components/AppShell' +import { SettingsClient } from '@/components/admin/SettingsClient' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { title: 'Settings' } + +export default async function AdminSettingsPage() { + const me = await requireRole('admin') + return ( + + + + ) +} diff --git a/voxscribe/app/admin/users/page.tsx b/voxscribe/app/admin/users/page.tsx new file mode 100644 index 00000000..db6f285e --- /dev/null +++ b/voxscribe/app/admin/users/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' +import { requireRole } from '@/server/web/page-guards' +import { AppShell } from '@/components/AppShell' +import { UsersClient } from '@/components/admin/UsersClient' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { title: 'Users' } + +export default async function AdminUsersPage() { + const me = await requireRole('admin') + return ( + + + + ) +} diff --git a/voxscribe/app/api/admin/settings/route.ts b/voxscribe/app/api/admin/settings/route.ts new file mode 100644 index 00000000..735d5cd6 --- /dev/null +++ b/voxscribe/app/api/admin/settings/route.ts @@ -0,0 +1,34 @@ +// GET /api/admin/settings — the full effective instance settings. +// PUT /api/admin/settings — replace any subset ({ appName?, tagline?, +// secondaryText?, theme?, themeVariant?, brandColor? }); returns the new +// effective record. Both admin-only. + +import { type NextRequest } from 'next/server' +import { guard, audit, json } from '@/server/web/http' +import { getSettings, updateSettings, SettingsError, httpErrorFor } from '@/server/services/settings' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + const auth = await guard('admin') + if ('res' in auth) return auth.res + return json(getSettings()) +} + +export async function PUT(req: NextRequest) { + const auth = await guard('admin') + if ('res' in auth) return auth.res + const body = await req.json().catch(() => ({})) + try { + const { settings, detail } = updateSettings(body) + if (detail) audit(auth, 'settings.update', detail) + return json(settings) + } catch (err) { + if (err instanceof SettingsError) { + const mapped = httpErrorFor(err) + return json(mapped.body, mapped.status) + } + throw err + } +} diff --git a/voxscribe/app/api/audit/route.ts b/voxscribe/app/api/audit/route.ts new file mode 100644 index 00000000..98d70bfc --- /dev/null +++ b/voxscribe/app/api/audit/route.ts @@ -0,0 +1,22 @@ +// GET ?page= — audit log, paged, newest first (admin), spec §8. + +import { type NextRequest } from 'next/server' +import { guard, json } from '@/server/web/http' +import * as auditRepo from '@/server/data/repositories/audit-repo' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const PAGE_SIZE = 50 + +export async function GET(req: NextRequest) { + const auth = await guard('admin') + if ('res' in auth) return auth.res + const page = Math.max(1, Number(new URL(req.url).searchParams.get('page')) || 1) + return json({ + entries: auditRepo.list(PAGE_SIZE, (page - 1) * PAGE_SIZE), + total: auditRepo.count(), + page, + pageSize: PAGE_SIZE, + }) +} diff --git a/voxscribe/app/api/auth/github/callback/route.ts b/voxscribe/app/api/auth/github/callback/route.ts new file mode 100644 index 00000000..6cf708c0 --- /dev/null +++ b/voxscribe/app/api/auth/github/callback/route.ts @@ -0,0 +1,64 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { + SESSION_COOKIE, + STATE_COOKIE, + checkAllowlist, + clearedCookieOptions, + exchangeCodeForToken, + fetchGithubProfile, + makeSessionToken, + resolveOnLogin, + sessionCookieOptions, + verifyStateToken, +} from '@/server/services/auth' +import * as auditRepo from '@/server/data/repositories/audit-repo' +import { config } from '@/server/config' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(req: NextRequest) { + const url = new URL(req.url) + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const cookieState = req.cookies.get(STATE_COOKIE)?.value + + const base = config.publicOrigin || req.url + + const loginError = (reason: string) => { + const res = NextResponse.redirect(new URL(`/login?error=${reason}`, base)) + res.cookies.set(STATE_COOKIE, '', clearedCookieOptions()) + return res + } + + // single-use, signed state check (CSRF / replay) + if (!code || !state || !cookieState || state !== cookieState || !verifyStateToken(state)) { + return loginError('state') + } + + try { + // The GitHub access token identifies the user here and is never persisted + // (spec §3). + const token = await exchangeCodeForToken(code) + const profile = await fetchGithubProfile(token) + + if (!(await checkAllowlist(profile, token))) { + return loginError('not_allowed') + } + + const user = resolveOnLogin(profile) + const session = makeSessionToken(profile, user.role) + try { + auditRepo.append({ githubId: profile.githubId, login: profile.login, action: 'auth.login' }) + } catch { + /* best-effort */ + } + + const res = NextResponse.redirect(new URL('/', base)) + res.cookies.set(SESSION_COOKIE, session, sessionCookieOptions()) + res.cookies.set(STATE_COOKIE, '', clearedCookieOptions()) + return res + } catch { + return loginError('oauth') + } +} diff --git a/voxscribe/app/api/auth/github/route.ts b/voxscribe/app/api/auth/github/route.ts new file mode 100644 index 00000000..2c330851 --- /dev/null +++ b/voxscribe/app/api/auth/github/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server' +import { buildAuthorizeUrl, makeStateToken, STATE_COOKIE, stateCookieOptions } from '@/server/services/auth' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + const state = makeStateToken() + const res = NextResponse.redirect(buildAuthorizeUrl(state)) + res.cookies.set(STATE_COOKIE, state, stateCookieOptions()) + return res +} diff --git a/voxscribe/app/api/auth/logout/route.ts b/voxscribe/app/api/auth/logout/route.ts new file mode 100644 index 00000000..b511908c --- /dev/null +++ b/voxscribe/app/api/auth/logout/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' +import { SESSION_COOKIE, clearedCookieOptions, getSession } from '@/server/services/auth' +import * as auditRepo from '@/server/data/repositories/audit-repo' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST() { + const session = await getSession() + if (session) { + try { + auditRepo.append({ githubId: session.sub, login: session.login, action: 'auth.logout' }) + } catch { + /* best-effort */ + } + } + const res = NextResponse.json({ ok: true }) + res.cookies.set(SESSION_COOKIE, '', clearedCookieOptions()) + return res +} diff --git a/voxscribe/app/api/health/route.ts b/voxscribe/app/api/health/route.ts new file mode 100644 index 00000000..7b3cb9f3 --- /dev/null +++ b/voxscribe/app/api/health/route.ts @@ -0,0 +1,10 @@ +// GET /api/health — public liveness probe (Docker HEALTHCHECK). + +import { NextResponse } from 'next/server' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + return NextResponse.json({ ok: true }) +} diff --git a/voxscribe/app/api/me/route.ts b/voxscribe/app/api/me/route.ts new file mode 100644 index 00000000..331f84bd --- /dev/null +++ b/voxscribe/app/api/me/route.ts @@ -0,0 +1,27 @@ +// GET /api/me — identity + role for the signed-in user (spec §8). Guarded by +// `authorize('standard')`: 401 unauthenticated, 403 for guests. + +import { NextResponse } from 'next/server' +import { authorize } from '@/server/services/auth' +import * as userRepo from '@/server/data/repositories/user-repo' +import type { MeResponse } from '@/server/domain/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + const authz = await authorize('standard') + if (!authz.ok) return NextResponse.json({ error: 'unauthorized' }, { status: authz.status }) + + const user = userRepo.get(authz.session.sub) + if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + + const body: MeResponse = { + githubId: user.githubId, + login: user.login, + name: user.name, + avatarUrl: user.avatarUrl, + role: authz.role, + } + return NextResponse.json(body) +} diff --git a/voxscribe/app/api/models/[name]/download/route.ts b/voxscribe/app/api/models/[name]/download/route.ts new file mode 100644 index 00000000..2e27d402 --- /dev/null +++ b/voxscribe/app/api/models/[name]/download/route.ts @@ -0,0 +1,23 @@ +// POST — start a background model download (admin). Progress is polled via +// GET /api/models; concurrent download of the same model → 409 (spec §8). + +import { type NextRequest } from 'next/server' +import { guard, audit, json } from '@/server/web/http' +import { startDownload, ModelStoreError } from '@/server/services/models' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST(_req: NextRequest, ctx: { params: Promise<{ name: string }> }) { + const auth = await guard('admin') + if ('res' in auth) return auth.res + const { name } = await ctx.params + try { + startDownload(name) + audit(auth, 'model.download', name) + return json({ ok: true }, 202) + } catch (err) { + if (err instanceof ModelStoreError) return json({ error: err.message }, err.status) + throw err + } +} diff --git a/voxscribe/app/api/models/[name]/route.ts b/voxscribe/app/api/models/[name]/route.ts new file mode 100644 index 00000000..2e56d0cf --- /dev/null +++ b/voxscribe/app/api/models/[name]/route.ts @@ -0,0 +1,22 @@ +// DELETE — remove a model blob (admin); refused while in use / downloading. + +import { type NextRequest } from 'next/server' +import { guard, audit, json } from '@/server/web/http' +import { deleteModel, ModelStoreError } from '@/server/services/models' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function DELETE(_req: NextRequest, ctx: { params: Promise<{ name: string }> }) { + const auth = await guard('admin') + if ('res' in auth) return auth.res + const { name } = await ctx.params + try { + await deleteModel(name) + audit(auth, 'model.delete', name) + return json({ ok: true }) + } catch (err) { + if (err instanceof ModelStoreError) return json({ error: err.message }, err.status) + throw err + } +} diff --git a/voxscribe/app/api/models/route.ts b/voxscribe/app/api/models/route.ts new file mode 100644 index 00000000..710e40c6 --- /dev/null +++ b/voxscribe/app/api/models/route.ts @@ -0,0 +1,20 @@ +// GET — allowed models + local presence + in-flight download pct (spec §8). +// Standard-guarded: the upload form needs it; mutations stay admin-only. + +import { guard, json } from '@/server/web/http' +import { listModels, resolveModelsForJob } from '@/server/services/models' +import { config } from '@/server/config' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const models = await listModels() + return json({ + models, + available: await resolveModelsForJob(), + defaultModel: config.defaultModel, + }) +} diff --git a/voxscribe/app/api/notes/[id]/download/route.ts b/voxscribe/app/api/notes/[id]/download/route.ts new file mode 100644 index 00000000..f175f3d1 --- /dev/null +++ b/voxscribe/app/api/notes/[id]/download/route.ts @@ -0,0 +1,40 @@ +// GET — raw .md, attachment filename `-.md` (spec §4.5). + +import { type NextRequest } from 'next/server' +import fs from 'node:fs' +import { Readable } from 'node:stream' +import { guard, json } from '@/server/web/http' +import * as notes from '@/server/services/notes' +import { NoteError, httpErrorFor } from '@/server/services/notes' +import { UnsafePathError } from '@/server/infrastructure/fs-media' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const { id } = await ctx.params + try { + const info = await notes.downloadInfo( + { githubId: auth.session.sub, login: auth.session.login, role: auth.role }, + id, + ) + const stream = fs.createReadStream(info.path) + return new Response(Readable.toWeb(stream) as unknown as ReadableStream, { + status: 200, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(info.filename)}"`, + 'Cache-Control': 'private, no-store', + }, + }) + } catch (err) { + if (err instanceof UnsafePathError) return json({ error: 'not found' }, 404) + if (err instanceof NoteError) { + const mapped = httpErrorFor(err) + return json(mapped.body, mapped.status) + } + throw err + } +} diff --git a/voxscribe/app/api/notes/[id]/route.ts b/voxscribe/app/api/notes/[id]/route.ts new file mode 100644 index 00000000..da660d96 --- /dev/null +++ b/voxscribe/app/api/notes/[id]/route.ts @@ -0,0 +1,67 @@ +// GET (metadata + markdown content) / PATCH (update) / DELETE — spec §4.5, §8. + +import { type NextRequest } from 'next/server' +import { guard, audit, json } from '@/server/web/http' +import * as notes from '@/server/services/notes' +import { NoteError, httpErrorFor } from '@/server/services/notes' +import { UnsafePathError } from '@/server/infrastructure/fs-media' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type Ctx = { params: Promise<{ id: string }> } + +function mapErr(err: unknown) { + if (err instanceof UnsafePathError) return json({ error: 'not found' }, 404) + if (err instanceof NoteError) { + const mapped = httpErrorFor(err) + return json(mapped.body, mapped.status) + } + return null +} + +export async function GET(_req: NextRequest, ctx: Ctx) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const { id } = await ctx.params + try { + const note = await notes.get( + { githubId: auth.session.sub, login: auth.session.login, role: auth.role }, + id, + ) + return json(note) + } catch (err) { + return mapErr(err) ?? Promise.reject(err) + } +} + +export async function PATCH(req: NextRequest, ctx: Ctx) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const { id } = await ctx.params + const body = await req.json().catch(() => ({})) + try { + await notes.update( + { githubId: auth.session.sub, login: auth.session.login, role: auth.role }, + id, + { title: body?.title, date: body?.date, tags: body?.tags, content: body?.content }, + ) + audit(auth, 'note.update', id) + return json({ ok: true }) + } catch (err) { + return mapErr(err) ?? Promise.reject(err) + } +} + +export async function DELETE(_req: NextRequest, ctx: Ctx) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const { id } = await ctx.params + try { + await notes.remove({ githubId: auth.session.sub, login: auth.session.login, role: auth.role }, id) + audit(auth, 'note.delete', id) + return json({ ok: true }) + } catch (err) { + return mapErr(err) ?? Promise.reject(err) + } +} diff --git a/voxscribe/app/api/notes/route.ts b/voxscribe/app/api/notes/route.ts new file mode 100644 index 00000000..3d782328 --- /dev/null +++ b/voxscribe/app/api/notes/route.ts @@ -0,0 +1,58 @@ +// GET /api/notes — list with multi-tag AND filter, date range, FTS search. +// POST /api/notes — create (JSON body). Spec §4.5, §8. + +import { type NextRequest } from 'next/server' +import { guard, audit, json } from '@/server/web/http' +import * as notes from '@/server/services/notes' +import { NoteError, httpErrorFor } from '@/server/services/notes' +import { normalizeTag } from '@/server/domain/note-validation' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const PAGE_SIZE = 25 + +export async function GET(req: NextRequest) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const url = new URL(req.url) + const tagsCsv = url.searchParams.get('tags') ?? '' + const tags = tagsCsv + .split(',') + .map((t) => normalizeTag(t)) + .filter(Boolean) + const ownerRaw = url.searchParams.get('owner') + const result = notes.list( + { githubId: auth.session.sub, login: auth.session.login, role: auth.role }, + { + tags: tags.length ? tags : undefined, + from: url.searchParams.get('from') ?? undefined, + to: url.searchParams.get('to') ?? undefined, + q: url.searchParams.get('q') ?? undefined, + owner: ownerRaw ? Number(ownerRaw) || undefined : undefined, + page: Math.max(1, Number(url.searchParams.get('page')) || 1), + pageSize: PAGE_SIZE, + }, + ) + return json(result) +} + +export async function POST(req: NextRequest) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const body = await req.json().catch(() => ({})) + try { + const { id } = await notes.create( + { githubId: auth.session.sub, login: auth.session.login, role: auth.role }, + { title: body?.title, date: body?.date, tags: body?.tags, content: body?.content }, + ) + audit(auth, 'note.create', id) + return json({ id }, 201) + } catch (err) { + if (err instanceof NoteError) { + const mapped = httpErrorFor(err) + return json(mapped.body, mapped.status) + } + throw err + } +} diff --git a/voxscribe/app/api/settings/route.ts b/voxscribe/app/api/settings/route.ts new file mode 100644 index 00000000..6c154c06 --- /dev/null +++ b/voxscribe/app/api/settings/route.ts @@ -0,0 +1,14 @@ +// GET /api/settings — the PUBLIC instance branding + theme projection. +// Unauthenticated: the login screen reads the app name + theme before any +// session exists. Every field is non-sensitive branding; mutation lives on the +// admin-gated /api/admin/settings. + +import { json } from '@/server/web/http' +import { getPublicSettings } from '@/server/services/settings' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + return json(getPublicSettings()) +} diff --git a/voxscribe/app/api/stats/route.ts b/voxscribe/app/api/stats/route.ts new file mode 100644 index 00000000..cd41e751 --- /dev/null +++ b/voxscribe/app/api/stats/route.ts @@ -0,0 +1,13 @@ +// GET — dashboard aggregates, scoped to the actor (spec §8). + +import { guard, json } from '@/server/web/http' +import { stats } from '@/server/services/stats' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + const auth = await guard('standard') + if ('res' in auth) return auth.res + return json(stats({ githubId: auth.session.sub, login: auth.session.login, role: auth.role })) +} diff --git a/voxscribe/app/api/tags/route.ts b/voxscribe/app/api/tags/route.ts new file mode 100644 index 00000000..4a371ae6 --- /dev/null +++ b/voxscribe/app/api/tags/route.ts @@ -0,0 +1,16 @@ +// GET — tag list + usage counts, scoped to the actor's own notes (admin: all). +// Scoping prevents tag names leaking across owners (spec §4.5). + +import { guard, json } from '@/server/web/http' +import * as notes from '@/server/services/notes' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + const auth = await guard('standard') + if ('res' in auth) return auth.res + return json({ + tags: notes.listTags({ githubId: auth.session.sub, login: auth.session.login, role: auth.role }), + }) +} diff --git a/voxscribe/app/api/transcriptions/[id]/audio/route.ts b/voxscribe/app/api/transcriptions/[id]/audio/route.ts new file mode 100644 index 00000000..fb30ee0c --- /dev/null +++ b/voxscribe/app/api/transcriptions/[id]/audio/route.ts @@ -0,0 +1,82 @@ +// GET — stream the original media with Range support for the synced player +// (spec §4.4). Streams from disk, zero-copy. + +import { type NextRequest } from 'next/server' +import fs from 'node:fs' +import { Readable } from 'node:stream' +import { guard, json } from '@/server/web/http' +import * as transcriptions from '@/server/services/transcriptions' +import { TranscriptionError, httpErrorFor } from '@/server/services/transcriptions' +import { UnsafePathError } from '@/server/infrastructure/fs-media' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const { id } = await ctx.params + + let info: transcriptions.ArtifactInfo + try { + info = await transcriptions.artifactInfo( + { githubId: auth.session.sub, login: auth.session.login, role: auth.role }, + id, + 'audio', + ) + } catch (err) { + if (err instanceof UnsafePathError) return json({ error: 'not found' }, 404) + if (err instanceof TranscriptionError) { + const mapped = httpErrorFor(err) + return json(mapped.body, mapped.status) + } + throw err + } + + const range = req.headers.get('range') + const common = { + 'Accept-Ranges': 'bytes', + 'Content-Type': info.contentType, + 'Cache-Control': 'private, no-store', + } + + if (range) { + const m = range.match(/^bytes=(\d*)-(\d*)$/) + if (!m || (m[1] === '' && m[2] === '')) { + return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${info.size}` } }) + } + let start: number + let end: number + if (m[1] === '') { + // suffix range: last N bytes + const suffix = Math.min(Number(m[2]), info.size) + start = info.size - suffix + end = info.size - 1 + } else { + start = Number(m[1]) + end = m[2] === '' ? info.size - 1 : Math.min(Number(m[2]), info.size - 1) + } + if (start > end || start >= info.size) { + return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${info.size}` } }) + } + const stream = fs.createReadStream(info.path, { start, end }) + return new Response(Readable.toWeb(stream) as unknown as ReadableStream, { + status: 206, + headers: { + ...common, + 'Content-Range': `bytes ${start}-${end}/${info.size}`, + 'Content-Length': String(end - start + 1), + }, + }) + } + + const stream = fs.createReadStream(info.path) + return new Response(Readable.toWeb(stream) as unknown as ReadableStream, { + status: 200, + headers: { + ...common, + 'Content-Length': String(info.size), + 'Content-Disposition': `attachment; filename="${encodeURIComponent(info.filename)}"`, + }, + }) +} diff --git a/voxscribe/app/api/transcriptions/[id]/retry/route.ts b/voxscribe/app/api/transcriptions/[id]/retry/route.ts new file mode 100644 index 00000000..dfbde97e --- /dev/null +++ b/voxscribe/app/api/transcriptions/[id]/retry/route.ts @@ -0,0 +1,28 @@ +// POST — failed → pending (spec §4.2, §8). + +import { type NextRequest } from 'next/server' +import { guard, audit, json } from '@/server/web/http' +import * as transcriptions from '@/server/services/transcriptions' +import { TranscriptionError, httpErrorFor } from '@/server/services/transcriptions' +import { UnsafePathError } from '@/server/infrastructure/fs-media' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const { id } = await ctx.params + try { + transcriptions.retry({ githubId: auth.session.sub, login: auth.session.login, role: auth.role }, id) + audit(auth, 'transcription.retry', id) + return json({ ok: true }) + } catch (err) { + if (err instanceof UnsafePathError) return json({ error: 'not found' }, 404) + if (err instanceof TranscriptionError) { + const mapped = httpErrorFor(err) + return json(mapped.body, mapped.status) + } + throw err + } +} diff --git a/voxscribe/app/api/transcriptions/[id]/route.ts b/voxscribe/app/api/transcriptions/[id]/route.ts new file mode 100644 index 00000000..b8e380c8 --- /dev/null +++ b/voxscribe/app/api/transcriptions/[id]/route.ts @@ -0,0 +1,73 @@ +// GET (detail + segments) / PATCH (rename) / DELETE — spec §8. + +import { type NextRequest } from 'next/server' +import { guard, audit, json } from '@/server/web/http' +import * as transcriptions from '@/server/services/transcriptions' +import { TranscriptionError, httpErrorFor } from '@/server/services/transcriptions' +import { UnsafePathError } from '@/server/infrastructure/fs-media' +import { validateTitle } from '@/server/domain/upload-validation' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type Ctx = { params: Promise<{ id: string }> } + +function mapErr(err: unknown) { + if (err instanceof UnsafePathError) return json({ error: 'not found' }, 404) + if (err instanceof TranscriptionError) { + const mapped = httpErrorFor(err) + return json(mapped.body, mapped.status) + } + return null +} + +export async function GET(_req: NextRequest, ctx: Ctx) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const { id } = await ctx.params + try { + const detail = await transcriptions.get( + { githubId: auth.session.sub, login: auth.session.login, role: auth.role }, + id, + ) + return json(detail) + } catch (err) { + return mapErr(err) ?? Promise.reject(err) + } +} + +export async function PATCH(req: NextRequest, ctx: Ctx) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const { id } = await ctx.params + const body = await req.json().catch(() => ({})) + const title = validateTitle(body?.title) + if (!title.ok) return json({ error: title.error.message }, 422) + try { + transcriptions.rename( + { githubId: auth.session.sub, login: auth.session.login, role: auth.role }, + id, + title.value, + ) + audit(auth, 'transcription.rename', `${id} → ${title.value}`) + return json({ ok: true }) + } catch (err) { + return mapErr(err) ?? Promise.reject(err) + } +} + +export async function DELETE(_req: NextRequest, ctx: Ctx) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const { id } = await ctx.params + try { + await transcriptions.remove( + { githubId: auth.session.sub, login: auth.session.login, role: auth.role }, + id, + ) + audit(auth, 'transcription.delete', id) + return json({ ok: true }) + } catch (err) { + return mapErr(err) ?? Promise.reject(err) + } +} diff --git a/voxscribe/app/api/transcriptions/[id]/transcript/route.ts b/voxscribe/app/api/transcriptions/[id]/transcript/route.ts new file mode 100644 index 00000000..89b00954 --- /dev/null +++ b/voxscribe/app/api/transcriptions/[id]/transcript/route.ts @@ -0,0 +1,48 @@ +// GET ?format=txt|srt|vtt|json — download a transcript artifact (spec §4.4). + +import { type NextRequest } from 'next/server' +import fs from 'node:fs' +import { Readable } from 'node:stream' +import { guard, json } from '@/server/web/http' +import * as transcriptions from '@/server/services/transcriptions' +import { TranscriptionError, httpErrorFor } from '@/server/services/transcriptions' +import { UnsafePathError } from '@/server/infrastructure/fs-media' +import type { TranscriptFormat } from '@/server/domain/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const FORMATS: TranscriptFormat[] = ['txt', 'srt', 'vtt', 'json'] + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const { id } = await ctx.params + const format = new URL(req.url).searchParams.get('format') ?? 'txt' + if (!FORMATS.includes(format as TranscriptFormat)) return json({ error: 'invalid format' }, 422) + + try { + const info = await transcriptions.artifactInfo( + { githubId: auth.session.sub, login: auth.session.login, role: auth.role }, + id, + format as TranscriptFormat, + ) + const stream = fs.createReadStream(info.path) + return new Response(Readable.toWeb(stream) as unknown as ReadableStream, { + status: 200, + headers: { + 'Content-Type': info.contentType, + 'Content-Length': String(info.size), + 'Content-Disposition': `attachment; filename="${encodeURIComponent(info.filename)}"`, + 'Cache-Control': 'private, no-store', + }, + }) + } catch (err) { + if (err instanceof UnsafePathError) return json({ error: 'not found' }, 404) + if (err instanceof TranscriptionError) { + const mapped = httpErrorFor(err) + return json(mapped.body, mapped.status) + } + throw err + } +} diff --git a/voxscribe/app/api/transcriptions/events/route.ts b/voxscribe/app/api/transcriptions/events/route.ts new file mode 100644 index 00000000..41f825b6 --- /dev/null +++ b/voxscribe/app/api/transcriptions/events/route.ts @@ -0,0 +1,16 @@ +// GET — SSE `job.updated` stream. Live events AND ring-buffer replay are +// filtered to the actor's own jobs unless admin (spec §4.2, §8). + +import { guard } from '@/server/web/http' +import { sseResponse } from '@/server/web/sse' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const isAdmin = auth.role === 'admin' + const sub = auth.session.sub + return sseResponse((event) => isAdmin || event.ownerId === sub) +} diff --git a/voxscribe/app/api/transcriptions/route.ts b/voxscribe/app/api/transcriptions/route.ts new file mode 100644 index 00000000..0894d5b1 --- /dev/null +++ b/voxscribe/app/api/transcriptions/route.ts @@ -0,0 +1,121 @@ +// GET /api/transcriptions — list (own; admin: all + ?owner=), FTS q, filters. +// POST /api/transcriptions — multipart upload → 201 { id }; duplicate & no +// force → 409 { duplicateOf } (spec §4.1, §8). +// +// The multipart body is parsed STREAMING with busboy over the raw request +// stream — never `req.formData()`, which materializes file parts in memory and +// breaks the RAM budget at 500 MB uploads (spec §7). API contract: option +// fields precede the file part (the upload client appends them first). + +import { type NextRequest } from 'next/server' +import { Readable } from 'node:stream' +import Busboy from 'busboy' +import { guard, audit, json, error } from '@/server/web/http' +import { config } from '@/server/config' +import * as transcriptions from '@/server/services/transcriptions' +import { TranscriptionError, httpErrorFor } from '@/server/services/transcriptions' +import type { TranscriptionStatus } from '@/server/domain/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const PAGE_SIZE = 25 +const STATUSES: TranscriptionStatus[] = ['pending', 'processing', 'done', 'failed'] + +export async function GET(req: NextRequest) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + + const url = new URL(req.url) + const statusRaw = url.searchParams.get('status') ?? undefined + const page = Math.max(1, Number(url.searchParams.get('page')) || 1) + const ownerRaw = url.searchParams.get('owner') + const result = transcriptions.list( + { githubId: auth.session.sub, login: auth.session.login, role: auth.role }, + { + status: STATUSES.includes(statusRaw as TranscriptionStatus) + ? (statusRaw as TranscriptionStatus) + : undefined, + language: url.searchParams.get('language') ?? undefined, + model: url.searchParams.get('model') ?? undefined, + q: url.searchParams.get('q') ?? undefined, + owner: ownerRaw ? Number(ownerRaw) || undefined : undefined, + page, + pageSize: PAGE_SIZE, + }, + ) + return json(result) +} + +export async function POST(req: NextRequest) { + const auth = await guard('standard') + if ('res' in auth) return auth.res + const actor = { githubId: auth.session.sub, login: auth.session.login, role: auth.role } + + const contentType = req.headers.get('content-type') ?? '' + if (!contentType.startsWith('multipart/form-data')) { + return error('expected multipart/form-data', 415) + } + + // Early cap rejection on Content-Length when present; the mid-stream byte + // counter in the service covers chunked bodies (spec §4.1). The multipart + // envelope adds a little overhead, so allow 1 MB of slack. + const declared = Number(req.headers.get('content-length')) || undefined + if (declared && declared > config.maxUploadBytes + 1024 * 1024) { + return error(`upload exceeds ${Math.round(config.maxUploadBytes / 1024 / 1024)} MB cap`, 413) + } + + if (!req.body) return error('empty body', 422) + + const fields: Record = {} + let uploadResult: Promise<{ id: string }> | null = null + + const bb = Busboy({ headers: { 'content-type': contentType }, limits: { files: 1 } }) + const parsed = new Promise((resolve, reject) => { + bb.on('field', (name, value) => { + fields[name] = value + }) + bb.on('file', (_name, stream, info) => { + // Fields arrive before the file (client appends options first); + // start the service pipeline immediately so the file streams straight + // to disk. On a service error, drain the rest so busboy can finish. + uploadResult = transcriptions + .createFromUpload(actor, stream, { + filename: info.filename || 'upload', + title: fields.title, + language: fields.language, + model: fields.model, + translate: fields.translate === 'true' || fields.translate === '1', + force: fields.force === 'true' || fields.force === '1', + declaredBytes: declared, + }) + .catch((err) => { + stream.resume() + throw err + }) + }) + bb.on('error', reject) + bb.on('close', resolve) + }) + + try { + const body = Readable.fromWeb(req.body as any) + const piping = new Promise((resolve, reject) => { + body.on('error', reject) + bb.on('error', reject) + body.pipe(bb) + bb.on('close', resolve) + }) + await Promise.all([parsed, piping]) + if (!uploadResult) return error('no file in upload', 422) + const { id } = await uploadResult + audit(auth, 'transcription.create', id) + return json({ id }, 201) + } catch (err) { + if (err instanceof TranscriptionError) { + const mapped = httpErrorFor(err) + return json(mapped.body, mapped.status) + } + throw err + } +} diff --git a/voxscribe/app/api/users/[id]/route.ts b/voxscribe/app/api/users/[id]/route.ts new file mode 100644 index 00000000..8f1abcbe --- /dev/null +++ b/voxscribe/app/api/users/[id]/route.ts @@ -0,0 +1,27 @@ +// PATCH — change a user's role (admin). Setting `guest` revokes access; the +// last-admin-demotion guard lives in the service (spec §6.5, §9). + +import { type NextRequest } from 'next/server' +import { guard, audit, json } from '@/server/web/http' +import { setRole, UserError } from '@/server/services/users' +import type { Role } from '@/server/domain/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const auth = await guard('admin') + if ('res' in auth) return auth.res + const { id } = await ctx.params + const githubId = Number(id) + if (!Number.isInteger(githubId)) return json({ error: 'invalid user id' }, 422) + const body = await req.json().catch(() => ({})) + try { + const user = setRole(githubId, body?.role as Role) + audit(auth, 'user.role', `${user.login} → ${user.role}`) + return json({ user }) + } catch (err) { + if (err instanceof UserError) return json({ error: err.message }, err.status) + throw err + } +} diff --git a/voxscribe/app/api/users/route.ts b/voxscribe/app/api/users/route.ts new file mode 100644 index 00000000..810c1bee --- /dev/null +++ b/voxscribe/app/api/users/route.ts @@ -0,0 +1,13 @@ +// GET — user list (admin), spec §8. + +import { guard, json } from '@/server/web/http' +import { listUsers } from '@/server/services/users' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + const auth = await guard('admin') + if ('res' in auth) return auth.res + return json({ users: listUsers() }) +} diff --git a/voxscribe/app/globals.css b/voxscribe/app/globals.css new file mode 100644 index 00000000..acc06a38 --- /dev/null +++ b/voxscribe/app/globals.css @@ -0,0 +1,2024 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* voxscribe-specific brand tokens layered on top of the @toolcase/web-components + design tokens (--tc-*). Everything cosmetic in this sheet reads a token so the + dark-mode block at the bottom can re-skin the whole app from one place. */ +:root { + /* Brand accent — sky blue, distinct from the library's slate + cyan. */ + --voxscribe-accent: #0ea5e9; + --voxscribe-accent-strong: #0284c7; + /* GitHub-Sponsors brand pink for the sponsor call-to-action. */ + --voxscribe-sponsor: #bf3989; + --voxscribe-sponsor-strong: #99316f; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; + line-height: 1.5; + color: var(--tc-text); + background: var(--tc-surface); +} + +/* Padding for the dashboard-layout content region (the default slot). */ +.voxscribe-shell-content { + padding: 1.5rem; +} + +/* The side-nav fills the sidebar-menu slot. */ +.voxscribe-sidebar-menu { + display: block; +} + +/* Brand-slot wrapper (AppShell): stretches across the sidebar header so the + optional tc-stamp can pin to its top-right corner (the stamp is absolutely + positioned and needs this position:relative anchor). */ +.voxscribe-brand-stamp { + position: relative; + flex: 1 1 auto; + align-self: stretch; + display: flex; + align-items: center; + min-width: 0; +} + +.voxscribe-brand-stamp tc-stamp { + /* Hug the header corner — the default 0.75rem offset would push the stamp + into the brand text inside the ~56px-tall header. */ + --bs-stamp-corner-offset: 0.3rem; +} + +/* Login screen (components/LoginClient.tsx). Full-height column: optional error + banner on top, then the tc-login card (or a redirect spinner) filling the rest. */ +.voxscribe-login { + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; +} + +.voxscribe-login-body { + flex: 1; + display: flex; +} + +.voxscribe-login-redirect { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + color: var(--tc-text-muted); + font-size: 0.9375rem; +} + +/* AuthGate loading / error placeholder, centered in the viewport. */ +.voxscribe-gate-status { + min-height: 100vh; + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + color: var(--tc-text-muted); + font-size: 0.9375rem; +} + +/* ── Create-site wizard (components/CreateSiteWizard.tsx, §14) ───────────────── */ + +.voxscribe-home { + max-width: 46rem; +} + +/* Unlike .voxscribe-admin / .voxscribe-plans (flex columns), .voxscribe-home is plain block + flow, so space the shared tc-rich-page-header from the body below it. */ +.voxscribe-home > tc-rich-page-header { + display: block; + margin-bottom: 1.5rem; +} + +/* Empty state — centre the "Create your first site" call-to-action. */ +.voxscribe-home-empty { + display: flex; + justify-content: center; + padding: 2.5rem 0; +} + +.voxscribe-home-lead { + margin: 0; + color: var(--tc-text-muted); +} + +.voxscribe-wizard { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Each step's content stack inside the wizard body. */ +.voxscribe-wizard-step { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.voxscribe-wizard-heading { + margin: 0; + font-size: 1.0625rem; + font-weight: 600; +} + +.voxscribe-wizard-hint { + margin: 0; + color: var(--tc-text-muted); + font-size: 0.875rem; +} + +.voxscribe-wizard-hint code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8125em; + background: var(--tc-surface-muted); + padding: 0.05rem 0.3rem; +} + +.voxscribe-wizard-field-label, +.voxscribe-wizard-host-mode .voxscribe-wizard-field-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--tc-text); +} + +.voxscribe-wizard-host-mode { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* label . base-domain row */ +.voxscribe-wizard-subdomain { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.voxscribe-wizard-subdomain > tc-input { + flex: 1 1 12rem; + min-width: 0; +} + +.voxscribe-wizard-subdomain > tc-extended-select { + flex: 1 1 14rem; + min-width: 0; +} + +.voxscribe-wizard-dot { + color: var(--tc-text-faint); + font-weight: 700; +} + +.voxscribe-wizard-preview { + margin: 0; + font-size: 0.875rem; + color: var(--tc-text-muted); +} + +.voxscribe-wizard-preview strong { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +/* Review summary grid. */ +.voxscribe-wizard-review { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.4rem 1rem; + margin: 0; + font-size: 0.9375rem; +} + +.voxscribe-wizard-review dt { + color: var(--tc-text-muted); +} + +.voxscribe-wizard-review dd { + margin: 0; + font-weight: 500; + overflow-wrap: anywhere; +} + +/* Inline validation / quota error, surfaced above the wizard. */ +.voxscribe-wizard-error { + padding: 0.75rem 1rem; + background: color-mix(in srgb, var(--tc-danger) 12%, var(--tc-surface)); + color: var(--tc-danger); + border: 1px solid color-mix(in srgb, var(--tc-danger) 35%, var(--tc-border)); + font-size: 0.9375rem; +} + +/* Post-create confirmation. */ +.voxscribe-wizard-success { + max-width: 36rem; + padding: 1.5rem; + border: 1px solid color-mix(in srgb, var(--tc-success) 35%, var(--tc-border)); + background: color-mix(in srgb, var(--tc-success) 10%, var(--tc-surface)); + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: flex-start; +} + +.voxscribe-wizard-success-title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--tc-success); +} + +.voxscribe-wizard-success-host { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 1rem; + color: var(--tc-success); +} + +.voxscribe-wizard-success-body { + margin: 0 0 0.5rem; + color: var(--tc-success); + font-size: 0.9375rem; +} + +/* ── Site dashboard (components/SiteDashboard.tsx, §9 §11 §14, task 734) ───────── */ + +/* The sites view is wider than the single-column create flow. Matches the shared + app width (.voxscribe-admin / .voxscribe-plans, 72rem) so / lines up with every other page. */ +.voxscribe-home--wide { + max-width: 72rem; +} + +.voxscribe-site-list { + display: flex; + flex-direction: column; + gap: 2rem; +} + +/* ── Site summary cards (components/SiteCard.tsx, §14) ─────────────────────────── */ + +/* Responsive grid of scannable cards; each links to its per-site page. */ +.voxscribe-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); + gap: 1.25rem; +} + +.voxscribe-card { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.25rem; + border: 1px solid var(--tc-border); + background: var(--tc-surface); + cursor: pointer; + transition: + border-color 0.12s ease, + box-shadow 0.12s ease, + transform 0.12s ease; +} + +.voxscribe-card:hover { + border-color: var(--voxscribe-accent); + box-shadow: var(--tc-shadow-sm); +} + +.voxscribe-card:focus-visible { + outline: 2px solid var(--voxscribe-accent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: no-preference) { + .voxscribe-card:active { + transform: translateY(1px); + } +} + +.voxscribe-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.voxscribe-card-id { + min-width: 0; +} + +.voxscribe-card-host { + margin: 0; + font-size: 1.0625rem; + font-weight: 600; + overflow-wrap: anywhere; +} + +.voxscribe-card-source { + margin: 0.125rem 0 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8125rem; + color: var(--tc-text-muted); + overflow-wrap: anywhere; +} + +.voxscribe-card-meta { + display: flex; + flex-wrap: wrap; + gap: 1.25rem; + margin: 0; +} + +.voxscribe-card-stat { + min-width: 0; +} + +.voxscribe-card-stat dt { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--tc-text-muted); +} + +.voxscribe-card-stat dd { + margin: 0.125rem 0 0; + font-size: 0.875rem; + color: var(--tc-text); +} + +/* P7: Wharf-style count badges on the site card. */ +.voxscribe-card-badges { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: auto; +} + +/* P6: account roll-up grid above the site grid. tc-metric-grid owns its own + mobile-first column cascade; just space it from the cards below. */ +.voxscribe-sites-metrics { + display: block; + margin-bottom: 1.5rem; +} + +/* Back link on the per-site page (components/SiteDetail.tsx). */ +/* Per-site page: a toolbar header, a note, a two-column body of self-framed + tc-* cards, and a custom-domain section. No outer card frame — the page reads + as a resource view and the inner tc-* cards carry the surfaces. */ +.voxscribe-site { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +/* Toolbar: status + identity on the left, primary action + overflow on the right, + separated from the body by a hairline rule. Wraps the action cluster on narrow. */ +.voxscribe-site-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + padding-bottom: 1rem; + border-bottom: 1px solid var(--tc-border); +} + +.voxscribe-site-ident { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.voxscribe-site-id { + min-width: 0; +} + +/* Destructive entry inside the toolbar's "More" overflow menu. */ +.voxscribe-site-menu .dropdown-item { + color: var(--tc-danger); +} + +.voxscribe-site-host { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + overflow-wrap: anywhere; +} + +.voxscribe-site-source { + margin: 0.125rem 0 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8125rem; + color: var(--tc-text-muted); + overflow-wrap: anywhere; +} + +.voxscribe-site-note { + margin: 0; + padding: 0.625rem 0.875rem; + background: var(--tc-surface-hover); + border-left: 3px solid var(--voxscribe-accent); + color: var(--tc-text); + font-size: 0.875rem; +} + +.voxscribe-site-loaderror { + margin: 0; + color: var(--tc-danger); + font-size: 0.875rem; +} + +/* Build + status on the left, storage on the right. tc-grid owns the column + template + mobile-first collapse (see SiteDashboard); this only keeps the + columns top-aligned. */ +.voxscribe-site-grid { + align-items: start; +} + +.voxscribe-site-col { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 0; +} + +.voxscribe-site-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.voxscribe-site-redeploy-msg { + color: var(--tc-text-muted); + font-size: 0.8125rem; +} + +/* Delete-confirm modal body — paragraph + type-to-confirm field + error stack. */ +.voxscribe-site-delete-body { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Type-to-confirm field: label above the hostname input. */ +.voxscribe-site-delete-confirm { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +@media (max-width: 48rem) { + /* Stack the toolbar: identity on top, full-width action cluster below. + (.voxscribe-site-grid's column collapse is owned by tc-grid's own cascade.) */ + .voxscribe-site-toolbar { + align-items: stretch; + } + + .voxscribe-site-actions { + justify-content: flex-end; + } +} + +/* ── Plans & sponsor (components/PlansView.tsx, §11 §14 §15, task 735) ─────────── */ + +.voxscribe-plans { + max-width: 72rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* The over-quota announcement bar + per-site banners stack above the cards. */ +.voxscribe-plans-warnings { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Four equal-height pricing cards. tc-grid owns the responsive column cascade + (columns 1 → sm 2 → lg 4); this only keeps the cards stretching to equal + height and stops a long feature line from overflowing its 1fr track. */ +.voxscribe-pricing-grid { + align-items: stretch; +} + +.voxscribe-pricing-grid > tc-pricing-card { + min-width: 0; +} + +/* Sponsor section: heading, CTA, then the live sponsor wall. */ +.voxscribe-sponsor { + margin-top: 1rem; + padding-top: 1.5rem; + border-top: 1px solid var(--tc-border); + display: flex; + flex-direction: column; + gap: 1rem; + align-items: flex-start; +} + +.voxscribe-sponsor-header { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +/* GitHub-Sponsors heart button — pink to match the Sponsors brand. */ +.voxscribe-sponsor-cta { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.1rem; + font-size: 0.9375rem; + font-weight: 600; + color: var(--voxscribe-sponsor); + text-decoration: none; + background: var(--tc-surface); + border: 1px solid color-mix(in srgb, var(--voxscribe-sponsor) 35%, var(--tc-border)); + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} + +.voxscribe-sponsor-cta:hover { + background: color-mix(in srgb, var(--voxscribe-sponsor) 12%, var(--tc-surface)); + border-color: var(--voxscribe-sponsor); + color: var(--voxscribe-sponsor-strong); +} + +.voxscribe-sponsor-cta:focus-visible { + outline: 2px solid var(--voxscribe-sponsor); + outline-offset: 2px; +} + +.voxscribe-sponsor-empty { + margin: 0; + color: var(--tc-text-muted); + font-size: 0.9375rem; +} + +.voxscribe-sponsor > tc-sponsor-wall { + width: 100%; + margin-top: 0.5rem; +} + +@media (prefers-reduced-motion: reduce) { + .voxscribe-sponsor-cta { + transition: none; + } +} + +/* ── Owner admin (components/OwnerAdmin.tsx, §13 §14, task 736) ────────────────── */ + +.voxscribe-admin { + max-width: 72rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* Each admin area's body inside its tc-section-card. */ +.voxscribe-admin-section { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +.voxscribe-admin-hint { + font-size: 0.875rem; +} + +.voxscribe-admin-hint code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85em; + background: var(--tc-surface-muted); + padding: 0.05rem 0.3rem; +} + +.voxscribe-admin-busy { + margin: 0; + color: var(--tc-text-muted); + font-size: 0.8125rem; +} + +.voxscribe-admin-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8125rem; +} + +/* Database access editor — preset pill row + operation checkbox grid. */ +.voxscribe-access-presets { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.voxscribe-access-ops { + display: grid; + grid-template-columns: 1fr; + gap: 0.625rem 1rem; +} + +@media (min-width: 640px) { + .voxscribe-access-ops { + grid-template-columns: 1fr 1fr; + } +} + +.voxscribe-access-op .voxscribe-admin-hint { + color: var(--tc-text-muted); + margin: 0.125rem 0 0 1.75rem; +} + +.voxscribe-access-op .voxscribe-admin-hint .voxscribe-admin-mono { + display: block; + color: var(--tc-text-faint); + font-size: 0.75rem; +} + +/* Operation summary under a Custom matrix cell. */ +.voxscribe-access-cell-detail { + margin-top: 0.25rem; + color: var(--tc-text-muted); + font-size: 0.75rem; + max-width: 14rem; +} + +/* Empty-table cell: the message renders through a nested tc-empty-state, which + brings its own generous padding. */ +.voxscribe-admin-empty-cell { + text-align: center; + padding: 0; +} + +.voxscribe-admin-empty-cell tc-empty-state { + --bs-empty-state-padding-y: 2rem; +} + +.voxscribe-admin-status { + text-transform: capitalize; + font-size: 0.8125rem; +} + +.voxscribe-admin-status--live { + color: var(--tc-success); +} + +.voxscribe-admin-status--failed, +.voxscribe-admin-status--suspended { + color: var(--tc-danger); +} + +.voxscribe-admin-status--over_quota { + color: var(--tc-warning); +} + +.voxscribe-admin-suspend-btn { + font: inherit; + font-size: 0.8125rem; + color: var(--tc-danger); + background: var(--tc-surface); + border: 1px solid color-mix(in srgb, var(--tc-danger) 35%, var(--tc-border)); + padding: 0.25rem 0.6rem; + cursor: pointer; +} + +.voxscribe-admin-suspend-btn:hover { + background: color-mix(in srgb, var(--tc-danger) 8%, var(--tc-surface)); + border-color: var(--tc-danger); +} + +.voxscribe-admin-suspend-btn:focus-visible { + outline: 2px solid var(--tc-danger); + outline-offset: 2px; +} + +.voxscribe-admin-suspended { + color: var(--tc-text-faint); + font-size: 0.8125rem; +} + +/* Base-domain list + add row. */ +.voxscribe-admin-list { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--tc-border); +} + +.voxscribe-admin-list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--tc-surface-muted); +} + +.voxscribe-admin-list-row:last-child { + border-bottom: none; +} + +.voxscribe-admin-add-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.voxscribe-admin-add-row > tc-input { + flex: 1 1 14rem; + min-width: 0; +} + +.voxscribe-admin-add-row > tc-extended-select { + flex: 0 1 13rem; + min-width: 9rem; +} + +/* Admin Settings form — two-column on wide, single-column on mobile (mobile-first). */ +.voxscribe-admin-settings { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.voxscribe-admin-settings-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; +} + +.voxscribe-admin-settings-full { + grid-column: 1 / -1; +} + +.voxscribe-admin-settings-actions { + display: flex; + justify-content: flex-end; +} + +@media (min-width: 640px) { + .voxscribe-admin-settings-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* Custom-domain A-record + Verify card on the site dashboard. */ +.voxscribe-site-domain { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.voxscribe-site-verify-result { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +/* ── tc-table admin/routing listings (P1) ────────────────────────────────────── + The admin (domains/realms/users) and routing (proxies/upstreams/streams/ + stream-upstreams) listings now render through tc-table fed escaped-HTML rows + (the relocation-safe pattern). These rules style the inline controls injected + into table cells so they match the surrounding tc-* form family. */ + +/* A native ${options}` + }, + }, + ], + [], + ) + + const onAction = useCallback( + (action: string, dataset: DOMStringMap, event: Event) => { + if (action !== 'role' || event.type !== 'change') return + const githubId = Number(dataset.id) + const role = (event.target as HTMLSelectElement).value + void apiFetch(`/api/users/${githubId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role }), + }) + .then(() => { + toast.show('Role updated', { variant: 'success' }) + void load() + }) + .catch(async (err) => { + toast.show(describeApiError(err), { variant: 'error' }) + void load() // reset the select to the authoritative value + }) + }, + [toast, load], + ) + + if (error) return + if (!users) return + + return ( +
+

Users

+

+ Setting guest revokes access. The first sign-in bootstrapped the first admin. +

+ []} + rowKey={(row) => String(row.githubId)} + onAction={onAction} + /> +
+ ) +} diff --git a/voxscribe/components/fields.tsx b/voxscribe/components/fields.tsx new file mode 100644 index 00000000..57100b25 --- /dev/null +++ b/voxscribe/components/fields.tsx @@ -0,0 +1,456 @@ +'use client' + +// Thin React wrappers over the @toolcase/web-components form inputs so voxscribe +// drives every input through tc-* (tc-input / tc-select / tc-check) instead of +// raw /