diff --git a/.env.example b/.env.example index a2515b0..3d5935c 100644 --- a/.env.example +++ b/.env.example @@ -8,15 +8,9 @@ DATABASE_URL=${{project.DATABASE_URL}} INTERNAL_API_KEY=${{project.INTERNAL_API_KEY}} DEBUG_KEY=${{project.DEBUG_KEY}} -PUBLIC_URL=${{project.PUBLIC_URL}} - -PROCESSOR_API_URL=${{project.PROCESSOR_API_URL}} -APP_BUILDER_API_URL=${{project.APP_BUILDER_API_URL}} -APP_BUILDER_ASSETS_PUBLIC_URL=${{project.APP_BUILDER_ASSETS_PUBLIC_URL}} - -CLIENT_DOWNLOADED_APK_NAME=${{project.CLIENT_DOWNLOADED_APK_NAME}} - -MOBILE_APP_API_URL=${{project.MOBILE_APP_API_URL}} +ORCHESTRATOR_API_URL=${{project.ORCHESTRATOR_API_URL}} +PUBLIC_WS_SCRCPY_SVC_URL=${{project.PUBLIC_WS_SCRCPY_SVC_URL}} +PUBLIC_FRONTEND_URL=${{project.PUBLIC_FRONTEND_URL}} BETTER_AUTH_SECRET=${{project.BETTER_AUTH_SECRET}} BETTER_AUTH_URL=${{project.BETTER_AUTH_URL}} @@ -34,14 +28,3 @@ OTEL_EXPORTER_OTLP_HTTP_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_HTTP_ENDPOINT}} OTEL_EXPORTER_OTLP_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_ENDPOINT}} OTEL_EXPORTER_OTLP_PROTOCOL=${{project.OTEL_EXPORTER_OTLP_PROTOCOL}} OTEL_RESOURCE_ATTRIBUTES=${{project.OTEL_RESOURCE_ATTRIBUTES}} - -R2_BUCKET_NAME=${{project.R2_BUCKET_NAME}} -R2_REGION=${{project.R2_REGION}} -R2_ENDPOINT=${{project.R2_ENDPOINT}} -R2_ACCESS_KEY=${{project.R2_ACCESS_KEY}} -R2_SECRET_KEY=${{project.R2_SECRET_KEY}} -R2_PUBLIC_URL=${{project.R2_PUBLIC_URL}} - -MAX_FILE_SIZE=${{project.MAX_FILE_SIZE}} -ALLOWED_MIME_TYPES=${{project.ALLOWED_MIME_TYPES}} -ALLOWED_EXTENSIONS=${{project.ALLOWED_EXTENSIONS}} diff --git a/AGENTS.md b/AGENTS.md index 01caa6c..de10358 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ More rules are only to be added by the human, in case such a suggestion becomes - **Monorepo**: Turborepo + pnpm - **Language**: TypeScript everywhere, Node >= 24 -- **Apps**: `@apps/main` (SvelteKit), `@apps/front` (Hono), `@apps/orchestrator` (Hono) +- **Apps**: `@apps/main` (SvelteKit), `@apps/frontend` (Hono), `@apps/orchestrator` (Hono) - **Packages**: `@pkg/logic`, `@pkg/db`, `@pkg/logger`, `@pkg/result`, `@pkg/keystore`, `@pkg/settings` - **DB**: PostgreSQL via Drizzle ORM; Redis (Valkey) via `@pkg/keystore` @@ -34,11 +34,13 @@ More rules are only to be added by the human, in case such a suggestion becomes All domain logic lives in `@pkg/logic` under `packages/logic/domains//` with four files: `data.ts`, `repository.ts`, `controller.ts`, `errors.ts`. Mirror this exactly when adding a domain. **Path aliases** (logic package only): + - `@/*` → `./*` · `@domains/*` → `./domains/*` · `@core/*` → `./core/*` **FlowExecCtx** (`fctx`) — passed into every domain operation for tracing: + ```ts -type FlowExecCtx = { flowId: string; userId?: string; sessionId?: string; }; +type FlowExecCtx = { flowId: string; userId?: string; sessionId?: string }; ``` --- diff --git a/README.md b/README.md index 423d2ba..1f3b133 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ Currently in alpha. Greenfield. Subject to change. ## How It Works 1. Admin generates a unique link and assigns it to a specific Android app on a specific device. -2. User opens that link in their browser — served by `apps/front`. -3. During the loading flow, `apps/front` validates the link and asks `apps/orchestrator` to reset the assigned Android session and launch the leased app. +2. User opens that link in their browser — served by `apps/frontend`. +3. During the loading flow, `apps/frontend` validates the link and asks `apps/orchestrator` to reset the assigned Android session and launch the leased app. 4. If that device is already in use by another end user, the link fails instead of taking over the session. 5. User is prompted to install the PWA. 6. User opens the PWA — they are routed into a live stream of their assigned Android app session. @@ -53,24 +53,24 @@ Currently in alpha. Greenfield. Subject to change. - [x] Link schema — DB model (unique token, expiry, status, linked device ID, leased app identity) - [x] Link domain in `@pkg/logic` — controller + repository + errors - [x] Admin dashboard: Links page — generate links, view detail, configure linked device + leased app, revoke, delete -- [ ] `apps/front`: validate incoming link token on request -- [ ] `apps/front`: during loading, reject the link if the assigned device is already `inUse` -- [ ] `apps/front`: call `apps/orchestrator` server-side to clean/reset the device and launch the leased app before handing off the session -- [ ] `apps/front`: return appropriate error page for invalid/expired/revoked links +- [ ] `apps/frontend`: validate incoming link token on request +- [ ] `apps/frontend`: during loading, reject the link if the assigned device is already `inUse` +- [ ] `apps/frontend`: call `apps/orchestrator` server-side to clean/reset the device and launch the leased app before handing off the session +- [ ] `apps/frontend`: return appropriate error page for invalid/expired/revoked links - [ ] Front: keep on checking for link status change, if it gets revoked, we cutoff the connection -### PWA & User Session Flow (`apps/front`) +### PWA & User Session Flow (`apps/frontend`) -- [ ] `apps/front`: serve static PWA shell (HTML + manifest + service worker) -- [ ] `apps/front`: wait/loading page — just for show with a 3-5s duration -- [ ] `apps/front`: PWA install prompt flow (beforeinstallprompt handling) -- [ ] `apps/front`: session binding — tie the PWA launch to the user's allocated device -- [ ] `apps/front`: route/proxy authenticated PWA requests to the Android instance stream +- [ ] `apps/frontend`: serve static PWA shell (HTML + manifest + service worker) +- [ ] `apps/frontend`: wait/loading page — just for show with a 3-5s duration +- [ ] `apps/frontend`: PWA install prompt flow (beforeinstallprompt handling) +- [ ] `apps/frontend`: session binding — tie the PWA launch to the user's allocated device +- [ ] `apps/frontend`: route/proxy authenticated PWA requests to the Android instance stream ### Android Streaming (scrcpy + ws-scrcpy) - [x] Docker-Android image setup and validation on VPS - [x] ws-scrcpy WebSocket server running per container, exposed via orchestrator -- [ ] `apps/front`: scrcpy client embedded in PWA — renders the Android stream in browser +- [ ] `apps/frontend`: scrcpy client embedded in PWA — renders the Android stream in browser - [ ] Input forwarding (touch/keyboard events → scrcpy → Android container) - [ ] Session timeout + stream teardown on inactivity diff --git a/apps/frontend/package.json b/apps/frontend/package.json index b28c10f..ac51dca 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "type": "module", "scripts": { - "dev": "vite dev --port 5173", + "dev": "vite dev --port 5174", "build": "NODE_ENV=build vite build", "prod": "HOST=0.0.0.0 PORT=3000 node ./build/index.js", "preview": "vite preview", diff --git a/apps/frontend/src/lib/core/constants.ts b/apps/frontend/src/lib/core/constants.ts index 9d1cd2f..3b8ead0 100644 --- a/apps/frontend/src/lib/core/constants.ts +++ b/apps/frontend/src/lib/core/constants.ts @@ -1,6 +1,6 @@ -import { PUBLIC_WS_SCRCPY_SVC_URL } from "$env/static/public"; +import { env } from "$env/dynamic/public"; -export const WS_SCRCPY_URL = PUBLIC_WS_SCRCPY_SVC_URL; +export const WS_SCRCPY_URL = env.PUBLIC_WS_SCRCPY_SVC_URL ?? ""; export const TRANSITION_COLORS = "transition-colors duration-150 ease-in-out"; export const TRANSITION_ALL = "transition-all duration-150 ease-in-out"; diff --git a/apps/frontend/src/lib/domains/link/client.ts b/apps/frontend/src/lib/domains/link/client.ts new file mode 100644 index 0000000..72a1368 --- /dev/null +++ b/apps/frontend/src/lib/domains/link/client.ts @@ -0,0 +1,39 @@ +import { resolveLinkSQ } from "./link.remote"; +import type { Err } from "@pkg/result"; + +export type SessionEndReason = + | "missing_token" + | "invalid" + | "expired" + | "revoked" + | "busy" + | "not_found" + | "network" + | "unknown"; + +export async function resolveLinkFresh(token: string) { + const query = resolveLinkSQ({ token }); + + if (query.ready) { + await query.refresh(); + return query.current; + } + + return await query; +} + +export function mapLinkErrorToSessionEndReason( + error?: Pick | null, +): SessionEndReason { + if (!error) return "unknown"; + + const text = `${error.code} ${error.message} ${error.description}`.toLowerCase(); + + if (text.includes("expired")) return "expired"; + if (text.includes("revoked")) return "revoked"; + if (text.includes("not found")) return "not_found"; + if (text.includes("busy") || text.includes("offline")) return "busy"; + if (text.includes("invalid") || text.includes("token")) return "invalid"; + + return "unknown"; +} diff --git a/apps/frontend/src/lib/domains/link/service.ts b/apps/frontend/src/lib/domains/link/service.ts index 873c8ab..ea849b2 100644 --- a/apps/frontend/src/lib/domains/link/service.ts +++ b/apps/frontend/src/lib/domains/link/service.ts @@ -12,6 +12,7 @@ import { formatErrorDetail, logDomainEvent } from "@pkg/logger"; import { DeviceStatus } from "@pkg/logic/domains/device/data"; import { ERROR_CODES, type Err } from "@pkg/result"; import { settings } from "@pkg/settings"; +import { WS_SCRCPY_URL } from "$lib/core/constants"; const lc = getLinkController(); @@ -35,6 +36,25 @@ type LinkSessionShape = { }; }; +const SCRCPY_SERVER_PORT = 8886; + +function buildStreamUrl(host: string, wsPort: string): string | null { + const trimmedHost = host.trim(); + const trimmedPort = wsPort.trim(); + + if (!trimmedHost || !trimmedPort) return null; + + const udid = `${trimmedHost}:${trimmedPort}`; + const baseWss = WS_SCRCPY_URL.replace(/^https:/, "wss:").replace( + /^http:/, + "ws:", + ); + const wsParam = + `${baseWss}/?action=proxy-adb&remote=${encodeURIComponent(`tcp:${SCRCPY_SERVER_PORT}`)}&udid=${encodeURIComponent(udid)}`; + + return `${WS_SCRCPY_URL}/#!action=stream&udid=${encodeURIComponent(udid)}&player=mse&ws=${encodeURIComponent(wsParam)}`; +} + async function getPreparedLinkContext(fctx: FlowExecCtx, token: string) { const linkResult = await lc.validate(fctx, token); if (linkResult.isErr()) { @@ -119,6 +139,7 @@ export async function resolveLinkFlow(fctx: FlowExecCtx, token: string) { isAvailable: link.device.status === DeviceStatus.ONLINE && !link.device.inUse, + streamUrl: buildStreamUrl(link.device.host, link.device.wsPort), }, supportedApp: link.supportedApp, }, diff --git a/apps/frontend/src/routes/+page.svelte b/apps/frontend/src/routes/+page.svelte index f4d7b91..c5da677 100644 --- a/apps/frontend/src/routes/+page.svelte +++ b/apps/frontend/src/routes/+page.svelte @@ -1,7 +1,81 @@ -base page to show the loading state +
+
+

Starting your session

+

{statusText}

+
+
+
+
+
diff --git a/apps/frontend/src/routes/session-ended/+page.svelte b/apps/frontend/src/routes/session-ended/+page.svelte new file mode 100644 index 0000000..b6036c6 --- /dev/null +++ b/apps/frontend/src/routes/session-ended/+page.svelte @@ -0,0 +1,26 @@ + + +
+
+

Session ended

+

+ {reasonMap[reason] || reasonMap.unknown} +

+
+
diff --git a/apps/frontend/src/routes/session/+page.svelte b/apps/frontend/src/routes/session/+page.svelte new file mode 100644 index 0000000..5788f65 --- /dev/null +++ b/apps/frontend/src/routes/session/+page.svelte @@ -0,0 +1,141 @@ + + +
+ {#if state === "loading"} +
+
+

Loading session

+

+ {statusText} +

+
+
+ {:else} +
+
+

{statusText}

+
+
+ {#if streamUrl} + + {:else} +
+

Waiting for stream...

+
+ {/if} +
+
+ {/if} +
diff --git a/apps/frontend/src/routes/unauthorized/+page.svelte b/apps/frontend/src/routes/unauthorized/+page.svelte new file mode 100644 index 0000000..2a4939c --- /dev/null +++ b/apps/frontend/src/routes/unauthorized/+page.svelte @@ -0,0 +1,21 @@ + + +
+
+

Access denied

+

+ {reasonMap[reason] || "This session link is not valid."} +

+
+
diff --git a/apps/main/src/lib/core/constants.ts b/apps/main/src/lib/core/constants.ts index ae52a54..77291d8 100644 --- a/apps/main/src/lib/core/constants.ts +++ b/apps/main/src/lib/core/constants.ts @@ -1,8 +1,8 @@ import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard"; -import { PUBLIC_WS_SCRCPY_SVC_URL } from "$env/static/public"; import AppWindow from "@lucide/svelte/icons/app-window"; import { BellRingIcon, Link } from "@lucide/svelte"; import UserCircle from "~icons/lucide/user-circle"; +import { env } from "$env/dynamic/public"; export type AppSidebarItem = { title: string; @@ -47,7 +47,7 @@ export const secondaryNavTree = [ }, ] as AppSidebarItem[]; -export const WS_SCRCPY_URL = PUBLIC_WS_SCRCPY_SVC_URL; +export const WS_SCRCPY_URL = env.PUBLIC_WS_SCRCPY_SVC_URL; export const COMPANY_NAME = "SaaS Template"; export const WEBSITE_URL = "https://company.com"; diff --git a/apps/main/src/routes/(main)/links/+page.svelte b/apps/main/src/routes/(main)/links/+page.svelte index 6543d36..b1604fe 100644 --- a/apps/main/src/routes/(main)/links/+page.svelte +++ b/apps/main/src/routes/(main)/links/+page.svelte @@ -8,6 +8,7 @@ import { Input } from "$lib/components/ui/input"; import { Label } from "$lib/components/ui/label"; import * as Table from "$lib/components/ui/table"; + import { env } from "$env/dynamic/public"; import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte"; import { mainNavTree } from "$lib/core/constants"; import { linkVM } from "$lib/domains/link/link.vm.svelte"; @@ -49,10 +50,18 @@ if (success) resetForm(); } - async function copyToken(token: string) { + function buildPublicLink(token: string): string { + const base = env.PUBLIC_FRONTEND_URL?.trim() || "/"; + const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"; + const url = new URL(base, origin); + url.searchParams.set("token", token); + return url.toString(); + } + + async function copyLink(token: string) { try { - await navigator.clipboard.writeText(token); - toast.success("Token copied to clipboard"); + await navigator.clipboard.writeText(buildPublicLink(token)); + toast.success("Link copied to clipboard"); } catch { toast.error("Failed to copy"); } @@ -145,7 +154,7 @@ @@ -270,7 +279,7 @@ diff --git a/memory.log.md b/memory.log.md index 69c9782..5966250 100644 --- a/memory.log.md +++ b/memory.log.md @@ -69,3 +69,18 @@ Update rule: - Re-condensed timeline from many micro-numbered entries into broader milestone groups. - Restored important implementation details while keeping the log substantially shorter and easier to scan. + +### 9 — Frontend Session Routing + Live Revocation Polling + +- Implemented token-gated frontend flow: `/` now validates + prepares link sessions and redirects to `/session` only after orchestrator preparation succeeds. +- Added explicit failure routes for invalid access and terminated sessions (`/unauthorized`, `/session-ended`) with reason-based messaging. +- Added session runtime polling (2s interval with forced query refresh + network failure tolerance) that ejects users immediately when link validity is lost. +- Extended link resolve payload with server-built ws-scrcpy stream URL data so the session route can render the active stream without exposing raw device details in the URL. + +### 10 — Frontend De-Branding Cleanup + +- Removed temporary `IOTAM` label text from session loading and failure pages to keep user-facing frontend copy generic. + +### 11 — Admin Link Copy Full URL + +- Refactored admin links-page copy action to copy full public frontend access URLs (`PUBLIC_FRONTEND_URL?token=...`) instead of raw tokens. diff --git a/packages/logic/domains/link/controller.ts b/packages/logic/domains/link/controller.ts index 984508d..9ea46dd 100644 --- a/packages/logic/domains/link/controller.ts +++ b/packages/logic/domains/link/controller.ts @@ -1,8 +1,3 @@ -import { errAsync, ResultAsync } from "neverthrow"; -import { nanoid } from "nanoid"; -import { db } from "@pkg/db"; -import { type Err } from "@pkg/result"; -import { FlowExecCtx } from "@core/flow.execution.context"; import { CreateLink, Link, @@ -10,8 +5,13 @@ import { LinkWithDevice, UpdateLink, } from "./data"; +import { FlowExecCtx } from "@core/flow.execution.context"; +import { errAsync, ResultAsync } from "neverthrow"; import { LinkRepository } from "./repository"; +import { type Err } from "@pkg/result"; import { linkErrors } from "./errors"; +import { nanoid } from "nanoid"; +import { db } from "@pkg/db"; export class LinkController { constructor(private repo: LinkRepository) {} @@ -26,9 +26,12 @@ export class LinkController { /** * Fetch a link by its URL token, including the joined device. - * Used by apps/front to validate and resolve an incoming link. + * Used by apps/frontend to validate and resolve an incoming link. */ - getByToken(fctx: FlowExecCtx, token: string): ResultAsync { + getByToken( + fctx: FlowExecCtx, + token: string, + ): ResultAsync { return this.repo.getByToken(fctx, token); } @@ -36,7 +39,10 @@ export class LinkController { * Validate a token: must exist, be active, and not be expired. * Returns the resolved link+device on success. */ - validate(fctx: FlowExecCtx, token: string): ResultAsync { + validate( + fctx: FlowExecCtx, + token: string, + ): ResultAsync { return this.repo.getByToken(fctx, token).andThen((l) => { if (l.status !== LinkStatus.ACTIVE) { return errAsync(linkErrors.linkNotActive(fctx, token)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab16f77..9c49c2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,70 +187,6 @@ importers: specifier: ^4.0.15 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) - apps/frontlegacy: - dependencies: - '@hono/node-server': - specifier: ^1.19.9 - version: 1.19.9(hono@4.12.8) - '@opentelemetry/api': - specifier: ^1.9.0 - version: 1.9.0 - '@opentelemetry/auto-instrumentations-node': - specifier: ^0.70.1 - version: 0.70.1(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0)) - '@opentelemetry/exporter-logs-otlp-proto': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': - specifier: ^2.1.0 - version: 2.5.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node': - specifier: ^0.212.0 - version: 0.212.0(@opentelemetry/api@1.9.0) - '@pkg/db': - specifier: workspace:* - version: link:../../packages/db - '@pkg/logger': - specifier: workspace:* - version: link:../../packages/logger - '@pkg/logic': - specifier: workspace:* - version: link:../../packages/logic - '@pkg/result': - specifier: workspace:* - version: link:../../packages/result - '@pkg/settings': - specifier: workspace:* - version: link:../../packages/settings - hono: - specifier: ^4.12.8 - version: 4.12.8 - import-in-the-middle: - specifier: ^3.0.0 - version: 3.0.0 - valibot: - specifier: ^1.2.0 - version: 1.2.0(typescript@5.9.3) - devDependencies: - '@types/node': - specifier: ^25.3.2 - version: 25.5.0 - tsx: - specifier: ^4.21.0 - version: 4.21.0 - typescript: - specifier: ^5.9.3 - version: 5.9.3 - apps/main: dependencies: '@opentelemetry/api':