From d44bbcceda375e646f532ba0e607f233015830b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:52:53 +0200 Subject: [PATCH] fix: QA hardening across desktop connect, CLI, setup, and PWA update flow (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - desktop: probe-then-persist server URL — scheme-less input tries https then http; a failed probe no longer saves a dead URL that would trap the next launch in a redirect to a broken address - frontend: close the create-collection modal on successful submit (backdrop stayed mounted and intercepted navigation) - frontend: reload once on service-worker controllerchange so returning users don't run a stale cached shell after a deploy - frontend: gate dashboard empty states on resource loading to stop the "No project selected" / "Create your workspace" flash - cli: resolve scheduled-job refs by name or id for jobs:run, jobs:runs, and jobs:rm, matching bucket/org resolution - cli: correct stale pk_live_/pk_test_ help text to the real prm_ format; align README command spellings and demo seed prefixes - setup: rewrite all public URL vars (auth, vite, backend) and cookie domain for the entered host — https for domains, http for IPs — with anchored full-line substitutions so stale ports/paths can't survive - docs: VITE_* vars never reach the frontend image build; the app resolves API/auth URLs same-origin at runtime, so host changes need no rebuild — noted HTTPS requirement for PWA/secure-context features Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- DEPLOYMENT_GUIDE.md | 10 ++++-- apps/cli/README.md | 4 +-- apps/cli/src/cli.ts | 2 +- apps/cli/src/commands/jobs.ts | 11 +++--- apps/cli/src/http.ts | 15 +++++++- apps/desktop/ui/index.html | 34 +++++++++++++------ apps/frontend/src/App.tsx | 11 +++++- .../src/lib/__tests__/slugdom.test.tsx | 28 +++++++++++++++ apps/frontend/src/lib/demo-mode.ts | 4 +-- apps/frontend/src/main.tsx | 13 +++++++ apps/frontend/src/pages/CollectionsPage.tsx | 8 ++++- scripts/setup.sh | 31 +++++++++++++---- 12 files changed, 138 insertions(+), 33 deletions(-) create mode 100644 apps/frontend/src/lib/__tests__/slugdom.test.tsx diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index cdc0693..c9b6c55 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -40,7 +40,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d | `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for project secrets (Vault page, `secret://` job refs), integration credentials + webhook secrets at rest | production | | `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes | | `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes | -| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes | +| `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 (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..amazonaws.com`), region, bucket | with `s3` | @@ -81,8 +81,12 @@ labels: ``` 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 -and rebuild the frontend image (`docker compose build frontend`). +`AUTH_BASE_URL`, `BETTER_AUTH_URL`, `BACKEND_PUBLIC_URL`, and +`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 diff --git a/apps/cli/README.md b/apps/cli/README.md index fcccea3..4137f08 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -21,7 +21,7 @@ project interactively. ## Commands `orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`, -`jobs`, `events:send`, `audit list --follow`. Every command accepts `--json`. +`jobs`, `events:send`, `audit:list --follow`. Every command accepts `--json`. ```sh primora events:send custom.deploy.done --data '{"sha":"abc123"}' @@ -31,7 +31,7 @@ primora events:send custom.deploy.done --data '{"sha":"abc123"}' webhooks, and `event_pattern` functions all fire. ```sh -primora documents list --collection users \ +primora documents:list --collection users \ --filter "status.eq.active,age.gt.18" --order "age.desc" ``` diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 8f4cd19..6ab7a45 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -51,7 +51,7 @@ cli .option("--url ", "Primora base URL (default http://localhost)") .option("--email ", "Account email") .option("--password ", "Account password") - .option("--api-key ", "Store a pk_live_/pk_test_ API key instead of a session") + .option("--api-key ", "Store a prm_ API key instead of a session") .option("--json", "Machine-readable output") .action(cmdLogin); diff --git a/apps/cli/src/commands/jobs.ts b/apps/cli/src/commands/jobs.ts index 0faa6b0..46123e8 100644 --- a/apps/cli/src/commands/jobs.ts +++ b/apps/cli/src/commands/jobs.ts @@ -2,7 +2,7 @@ import type { ScheduledJob, ScheduledJobRun } from "@primora/api-client"; import { AutomationService } from "@primora/api-client"; 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"; interface JobOptions { @@ -88,12 +88,13 @@ export async function cmdJobsCreate( } } -export async function cmdJobsRemove(jobId: string, options: JobOptions & { yes?: boolean }): Promise { +export async function cmdJobsRemove(jobRef: string, options: JobOptions & { yes?: boolean }): Promise { const cfg = loadConfig(); requireAuth(cfg); configureClient(cfg); const projectId = requireProject(cfg, options.project); + const jobId = await resolveJobId(projectId, jobRef); await AutomationService.deleteScheduledJob({ projectId, jobId }); if (isJson(options)) { 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 { +export async function cmdJobsRun(jobRef: string, options: JobOptions): Promise { const cfg = loadConfig(); requireAuth(cfg); configureClient(cfg); const projectId = requireProject(cfg, options.project); + const jobId = await resolveJobId(projectId, jobRef); const run = await AutomationService.runScheduledJob({ projectId, jobId }); if (isJson(options)) { printJson(run); @@ -128,7 +130,7 @@ function runRow(run: ScheduledJobRun): string[] { } export async function cmdJobsRuns( - jobId: string, + jobRef: string, options: JobOptions & { limit?: string }, ): Promise { const cfg = loadConfig(); @@ -136,6 +138,7 @@ export async function cmdJobsRuns( configureClient(cfg); const projectId = requireProject(cfg, options.project); + const jobId = await resolveJobId(projectId, jobRef); const page = await AutomationService.listScheduledJobRuns({ projectId, jobId, diff --git a/apps/cli/src/http.ts b/apps/cli/src/http.ts index 6fdd764..bb65730 100644 --- a/apps/cli/src/http.ts +++ b/apps/cli/src/http.ts @@ -1,4 +1,4 @@ -import { OpenAPI, StorageService } from "@primora/api-client"; +import { AutomationService, OpenAPI, StorageService } from "@primora/api-client"; import type { CliConfig } from "./config.js"; @@ -110,3 +110,16 @@ export async function resolveBucketId(projectId: string, ref: string): Promise { + const { items } = await AutomationService.listScheduledJobs({ projectId }); + const match = items.find((j) => j.id === ref || j.name === ref); + if (!match) { + throw new CliError( + `Job "${ref}" not found.`, + `Known jobs: ${items.map((j) => j.name).join(", ") || "(none)"}`, + ); + } + return match.id; +} diff --git a/apps/desktop/ui/index.html b/apps/desktop/ui/index.html index f4e421d..a30f417 100644 --- a/apps/desktop/ui/index.html +++ b/apps/desktop/ui/index.html @@ -101,6 +101,18 @@ tauri?.event.emit("open-vault"); }); + const probe = async (url) => { + try { + // Opaque (no-cors) — proves the host answers, not that it is a + // Primora deployment. A wrong-but-alive URL is recoverable via the + // tray menu's "Change server". + await fetch(url, { mode: "no-cors", signal: AbortSignal.timeout(8000) }); + return true; + } catch { + return false; + } + }; + form.addEventListener("submit", async (event) => { event.preventDefault(); err.hidden = true; @@ -108,7 +120,17 @@ button.textContent = "Checking…"; let url = input.value.trim(); - if (!/^https?:\/\//i.test(url)) url = `https://${url}`; + if (!/^https?:\/\//i.test(url)) { + // Self-hosted deployments are often plain HTTP on a LAN — probe + // https first, fall back to http, and only keep the one that answers. + const https = `https://${url}`; + url = (await probe(https)) ? https : `http://${url}`; + } + + if (!(await probe(url))) { + fail("Server did not respond. Check the URL and that the deployment is up."); + return; + } try { await tauri.core.invoke("set_server_url", { url }); @@ -117,16 +139,6 @@ return; } - // Reachability probe — opaque (no-cors), so it proves the host answers, - // not that it is a Primora deployment. A wrong-but-alive URL is recoverable - // via the tray menu's "Change server". - try { - await fetch(url, { mode: "no-cors", signal: AbortSignal.timeout(8000) }); - } catch { - fail("Server did not respond. Check the URL and that the deployment is up."); - return; - } - location.replace(url); }); diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 880ffa2..3230c58 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1637,7 +1637,7 @@ export default function App() {
@@ -1656,6 +1656,14 @@ export default function App() { when={activeProject()} fallback={
+ +
+
+ } + > 0} fallback={ @@ -1702,6 +1710,7 @@ export default function App() {
+
} > diff --git a/apps/frontend/src/lib/__tests__/slugdom.test.tsx b/apps/frontend/src/lib/__tests__/slugdom.test.tsx new file mode 100644 index 0000000..42626e1 --- /dev/null +++ b/apps/frontend/src/lib/__tests__/slugdom.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { render } from 'solid-js/web'; +import { createSignal } from 'solid-js'; +import { applySlugField } from '../slug'; +import { Input } from '../../components/Input'; + +describe('slug auto-derive in DOM', () => { + it('typing name fills the slug input', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const [input, setInput] = createSignal({ name: '', slug: '' }); + const dispose = render(() => ( +
+ setInput((c) => applySlugField(c, 'name', e.currentTarget.value))} /> + setInput((c) => applySlugField(c, 'slug', e.currentTarget.value))} /> +
+ ), host); + const [name, slug] = Array.from(host.querySelectorAll('input')); + name.value = 'Acme Corp'; + name.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((r) => setTimeout(r, 0)); + expect(slug.value).toBe('acme-corp'); + dispose(); + host.remove(); + }); +}); diff --git a/apps/frontend/src/lib/demo-mode.ts b/apps/frontend/src/lib/demo-mode.ts index 81178ea..b297205 100644 --- a/apps/frontend/src/lib/demo-mode.ts +++ b/apps/frontend/src/lib/demo-mode.ts @@ -216,7 +216,7 @@ let apiKeys: ApiKey[] = [ id: "key-1", project_id: "proj-1", name: "Production key", - prefix: "pk_live_7f2a", + prefix: "prm_7f2a9c", last_used_at: ago(0.2), revoked_at: null, }, @@ -224,7 +224,7 @@ let apiKeys: ApiKey[] = [ id: "key-2", project_id: "proj-1", name: "CI deploy", - prefix: "pk_test_9c41", + prefix: "prm_9c41de", last_used_at: ago(2), revoked_at: null, }, diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index f72ed22..510a48c 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -3,4 +3,17 @@ import { render } from "solid-js/web"; import App from "./App"; import "./index.css"; +// Reload once when an updated service worker takes control. Without this the +// precached shell keeps serving the previous release until a manual refresh. +// Skipped when the page has no controller yet (first SW install) so first-time +// visitors don't get an extra reload. +if ("serviceWorker" in navigator && navigator.serviceWorker.controller) { + let refreshing = false; + navigator.serviceWorker.addEventListener("controllerchange", () => { + if (refreshing) return; + refreshing = true; + window.location.reload(); + }); +} + render(() => , document.getElementById("root")!); diff --git a/apps/frontend/src/pages/CollectionsPage.tsx b/apps/frontend/src/pages/CollectionsPage.tsx index 6bce3a0..1e37016 100644 --- a/apps/frontend/src/pages/CollectionsPage.tsx +++ b/apps/frontend/src/pages/CollectionsPage.tsx @@ -253,7 +253,13 @@ export function CollectionsPage(props: CollectionsPageProps) { title="Create collection" description="Collections store JSON documents scoped to a project." > -
+ { + props.onCreateCollection(e); + setCreateOpen(false); + }} + class="space-y-4" + >