Implement frontend session routing flow

- Validate and prepare access links in apps/frontend
- Add session, ended, and unauthorized routes with polling
- Copy full public access URLs from the admin links page
This commit is contained in:
user
2026-03-28 19:10:24 +02:00
parent 31a501f75b
commit 92deee1b2e
16 changed files with 392 additions and 119 deletions

View File

@@ -8,15 +8,9 @@ DATABASE_URL=${{project.DATABASE_URL}}
INTERNAL_API_KEY=${{project.INTERNAL_API_KEY}} INTERNAL_API_KEY=${{project.INTERNAL_API_KEY}}
DEBUG_KEY=${{project.DEBUG_KEY}} DEBUG_KEY=${{project.DEBUG_KEY}}
PUBLIC_URL=${{project.PUBLIC_URL}} ORCHESTRATOR_API_URL=${{project.ORCHESTRATOR_API_URL}}
PUBLIC_WS_SCRCPY_SVC_URL=${{project.PUBLIC_WS_SCRCPY_SVC_URL}}
PROCESSOR_API_URL=${{project.PROCESSOR_API_URL}} PUBLIC_FRONTEND_URL=${{project.PUBLIC_FRONTEND_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}}
BETTER_AUTH_SECRET=${{project.BETTER_AUTH_SECRET}} BETTER_AUTH_SECRET=${{project.BETTER_AUTH_SECRET}}
BETTER_AUTH_URL=${{project.BETTER_AUTH_URL}} 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_ENDPOINT=${{project.OTEL_EXPORTER_OTLP_ENDPOINT}}
OTEL_EXPORTER_OTLP_PROTOCOL=${{project.OTEL_EXPORTER_OTLP_PROTOCOL}} OTEL_EXPORTER_OTLP_PROTOCOL=${{project.OTEL_EXPORTER_OTLP_PROTOCOL}}
OTEL_RESOURCE_ATTRIBUTES=${{project.OTEL_RESOURCE_ATTRIBUTES}} 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}}

View File

@@ -23,7 +23,7 @@ More rules are only to be added by the human, in case such a suggestion becomes
- **Monorepo**: Turborepo + pnpm - **Monorepo**: Turborepo + pnpm
- **Language**: TypeScript everywhere, Node >= 24 - **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` - **Packages**: `@pkg/logic`, `@pkg/db`, `@pkg/logger`, `@pkg/result`, `@pkg/keystore`, `@pkg/settings`
- **DB**: PostgreSQL via Drizzle ORM; Redis (Valkey) via `@pkg/keystore` - **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/<domain>/` with four files: `data.ts`, `repository.ts`, `controller.ts`, `errors.ts`. Mirror this exactly when adding a domain. All domain logic lives in `@pkg/logic` under `packages/logic/domains/<domain>/` with four files: `data.ts`, `repository.ts`, `controller.ts`, `errors.ts`. Mirror this exactly when adding a domain.
**Path aliases** (logic package only): **Path aliases** (logic package only):
- `@/*``./*` · `@domains/*``./domains/*` · `@core/*``./core/*` - `@/*``./*` · `@domains/*``./domains/*` · `@core/*``./core/*`
**FlowExecCtx** (`fctx`) — passed into every domain operation for tracing: **FlowExecCtx** (`fctx`) — passed into every domain operation for tracing:
```ts ```ts
type FlowExecCtx = { flowId: string; userId?: string; sessionId?: string; }; type FlowExecCtx = { flowId: string; userId?: string; sessionId?: string };
``` ```
--- ---

View File

@@ -9,8 +9,8 @@ Currently in alpha. Greenfield. Subject to change.
## How It Works ## How It Works
1. Admin generates a unique link and assigns it to a specific Android app on a specific device. 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`. 2. User opens that link in their browser — served by `apps/frontend`.
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. 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. 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. 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. 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 schema — DB model (unique token, expiry, status, linked device ID, leased app identity)
- [x] Link domain in `@pkg/logic` — controller + repository + errors - [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 - [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/frontend`: validate incoming link token on request
- [ ] `apps/front`: during loading, reject the link if the assigned device is already `inUse` - [ ] `apps/frontend`: 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/frontend`: 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`: 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 - [ ] 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/frontend`: serve static PWA shell (HTML + manifest + service worker)
- [ ] `apps/front`: wait/loading page — just for show with a 3-5s duration - [ ] `apps/frontend`: wait/loading page — just for show with a 3-5s duration
- [ ] `apps/front`: PWA install prompt flow (beforeinstallprompt handling) - [ ] `apps/frontend`: PWA install prompt flow (beforeinstallprompt handling)
- [ ] `apps/front`: session binding — tie the PWA launch to the user's allocated device - [ ] `apps/frontend`: 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`: route/proxy authenticated PWA requests to the Android instance stream
### Android Streaming (scrcpy + ws-scrcpy) ### Android Streaming (scrcpy + ws-scrcpy)
- [x] Docker-Android image setup and validation on VPS - [x] Docker-Android image setup and validation on VPS
- [x] ws-scrcpy WebSocket server running per container, exposed via orchestrator - [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) - [ ] Input forwarding (touch/keyboard events → scrcpy → Android container)
- [ ] Session timeout + stream teardown on inactivity - [ ] Session timeout + stream teardown on inactivity

View File

@@ -4,7 +4,7 @@
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --port 5173", "dev": "vite dev --port 5174",
"build": "NODE_ENV=build vite build", "build": "NODE_ENV=build vite build",
"prod": "HOST=0.0.0.0 PORT=3000 node ./build/index.js", "prod": "HOST=0.0.0.0 PORT=3000 node ./build/index.js",
"preview": "vite preview", "preview": "vite preview",

View File

@@ -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_COLORS = "transition-colors duration-150 ease-in-out";
export const TRANSITION_ALL = "transition-all duration-150 ease-in-out"; export const TRANSITION_ALL = "transition-all duration-150 ease-in-out";

View File

@@ -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<Err, "code" | "message" | "description"> | 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";
}

View File

@@ -12,6 +12,7 @@ import { formatErrorDetail, logDomainEvent } from "@pkg/logger";
import { DeviceStatus } from "@pkg/logic/domains/device/data"; import { DeviceStatus } from "@pkg/logic/domains/device/data";
import { ERROR_CODES, type Err } from "@pkg/result"; import { ERROR_CODES, type Err } from "@pkg/result";
import { settings } from "@pkg/settings"; import { settings } from "@pkg/settings";
import { WS_SCRCPY_URL } from "$lib/core/constants";
const lc = getLinkController(); 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) { async function getPreparedLinkContext(fctx: FlowExecCtx, token: string) {
const linkResult = await lc.validate(fctx, token); const linkResult = await lc.validate(fctx, token);
if (linkResult.isErr()) { if (linkResult.isErr()) {
@@ -119,6 +139,7 @@ export async function resolveLinkFlow(fctx: FlowExecCtx, token: string) {
isAvailable: isAvailable:
link.device.status === DeviceStatus.ONLINE && link.device.status === DeviceStatus.ONLINE &&
!link.device.inUse, !link.device.inUse,
streamUrl: buildStreamUrl(link.device.host, link.device.wsPort),
}, },
supportedApp: link.supportedApp, supportedApp: link.supportedApp,
}, },

View File

@@ -1,7 +1,81 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation";
import {
mapLinkErrorToSessionEndReason,
resolveLinkFresh,
} from "$lib/domains/link/client";
import { prepareLinkSC } from "$lib/domains/link/link.remote";
import { onMount } from "svelte"; import { onMount } from "svelte";
onMount(() => {}); let state: "boot" | "validating" | "preparing" = "boot";
let statusText = "Initializing session...";
onMount(() => {
const run = async () => {
const token =
new URLSearchParams(window.location.search)
.get("token")
?.trim() || "";
if (!token) {
await goto("/unauthorized?reason=missing_token", {
replaceState: true,
});
return;
}
state = "validating";
statusText = "Validating your access link...";
const resolved = await resolveLinkFresh(token);
if (resolved?.error || !resolved?.data) {
const reason = mapLinkErrorToSessionEndReason(resolved?.error);
await goto(
`/session-ended?reason=${encodeURIComponent(reason)}`,
{
replaceState: true,
},
);
return;
}
state = "preparing";
statusText = "Preparing your app session...";
const prepared = await prepareLinkSC({ token });
if (prepared?.error || !prepared?.data) {
const reason = mapLinkErrorToSessionEndReason(prepared?.error);
await goto(
`/session-ended?reason=${encodeURIComponent(reason)}`,
{
replaceState: true,
},
);
return;
}
await goto(`/session?token=${encodeURIComponent(token)}`, {
replaceState: true,
});
};
void run();
});
</script> </script>
<span>base page to show the loading state</span> <main
class="bg-background text-foreground flex min-h-screen items-center justify-center px-6"
>
<section
class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center"
>
<h1 class="text-xl font-semibold">Starting your session</h1>
<p class="mt-3 text-sm text-muted-foreground">{statusText}</p>
<div class="mt-5 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="bg-primary h-full animate-pulse"
style={`width: ${state === "boot" ? "35%" : state === "validating" ? "65%" : "88%"}`}
></div>
</div>
</section>
</main>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { browser } from "$app/environment";
const reasonMap: Record<string, string> = {
revoked: "This session was revoked by an administrator.",
expired: "This session expired.",
busy: "The assigned device is not currently available.",
not_found: "This session link could not be found.",
invalid: "This session link is invalid.",
network: "Connection was lost. Please try again.",
unknown: "This session is no longer available.",
};
const reason = browser
? (new URLSearchParams(window.location.search).get("reason") ?? "unknown")
: "unknown";
</script>
<main class="bg-background text-foreground flex min-h-screen items-center justify-center px-6">
<section class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center">
<h1 class="text-xl font-semibold">Session ended</h1>
<p class="mt-3 text-sm text-muted-foreground">
{reasonMap[reason] || reasonMap.unknown}
</p>
</section>
</main>

View File

@@ -0,0 +1,141 @@
<script lang="ts">
import { goto } from "$app/navigation";
import {
mapLinkErrorToSessionEndReason,
resolveLinkFresh,
} from "$lib/domains/link/client";
import { onMount } from "svelte";
const POLL_INTERVAL_MS = 2000;
const MAX_CONSECUTIVE_POLL_ERRORS = 3;
let state: "loading" | "ready" = "loading";
let token = "";
let streamUrl: string | null = null;
let statusText = "Connecting to your active session...";
onMount(() => {
let disposed = false;
let timer: ReturnType<typeof setInterval> | null = null;
let inFlight = false;
let consecutivePollErrors = 0;
const endSession = async (reason: string) => {
if (disposed) return;
if (timer) clearInterval(timer);
streamUrl = null;
await goto(`/session-ended?reason=${encodeURIComponent(reason)}`, {
replaceState: true,
});
};
const pollValidity = async () => {
if (disposed || inFlight || !token) return;
inFlight = true;
try {
const result = await resolveLinkFresh(token);
if (disposed) return;
if (result?.error || !result?.data) {
await endSession(
mapLinkErrorToSessionEndReason(result?.error),
);
return;
}
consecutivePollErrors = 0;
if (result.data.link.status !== "active") {
await endSession("expired");
return;
}
const nextStreamUrl = result.data.device.streamUrl;
if (typeof nextStreamUrl === "string" && nextStreamUrl.length > 0) {
streamUrl = nextStreamUrl;
}
} catch {
consecutivePollErrors += 1;
if (consecutivePollErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
await endSession("network");
}
} finally {
inFlight = false;
}
};
const start = async () => {
token =
new URLSearchParams(window.location.search)
.get("token")
?.trim() || "";
if (!token) {
await goto("/unauthorized?reason=missing_token", {
replaceState: true,
});
return;
}
const initial = await resolveLinkFresh(token);
if (initial?.error || !initial?.data) {
await endSession(mapLinkErrorToSessionEndReason(initial?.error));
return;
}
streamUrl = initial.data.device.streamUrl;
state = "ready";
statusText = "Session active";
await pollValidity();
timer = setInterval(() => {
void pollValidity();
}, POLL_INTERVAL_MS);
};
void start();
return () => {
disposed = true;
if (timer) clearInterval(timer);
};
});
</script>
<main class="bg-background text-foreground min-h-screen">
{#if state === "loading"}
<section class="flex min-h-screen items-center justify-center px-6">
<div
class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center"
>
<h1 class="text-xl font-semibold">Loading session</h1>
<p class="mt-3 text-sm text-muted-foreground">
{statusText}
</p>
</div>
</section>
{:else}
<section class="flex min-h-screen flex-col">
<div class="flex items-center justify-center border-b border-border px-3 py-2">
<p class="text-xs text-muted-foreground">{statusText}</p>
</div>
<div class="bg-black flex-1">
{#if streamUrl}
<iframe
src={streamUrl}
title="Android session"
class="h-full w-full border-0"
allow="autoplay; fullscreen"
referrerpolicy="no-referrer"
></iframe>
{:else}
<div class="flex h-full items-center justify-center">
<p class="text-sm text-white/70">Waiting for stream...</p>
</div>
{/if}
</div>
</section>
{/if}
</main>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { browser } from "$app/environment";
const reasonMap: Record<string, string> = {
missing_token: "Missing access token",
invalid: "Invalid access link",
};
const reason = browser
? (new URLSearchParams(window.location.search).get("reason") ?? "invalid")
: "invalid";
</script>
<main class="bg-background text-foreground flex min-h-screen items-center justify-center px-6">
<section class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-center">
<h1 class="text-xl font-semibold">Access denied</h1>
<p class="mt-3 text-sm text-muted-foreground">
{reasonMap[reason] || "This session link is not valid."}
</p>
</section>
</main>

View File

@@ -1,8 +1,8 @@
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard"; 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 AppWindow from "@lucide/svelte/icons/app-window";
import { BellRingIcon, Link } from "@lucide/svelte"; import { BellRingIcon, Link } from "@lucide/svelte";
import UserCircle from "~icons/lucide/user-circle"; import UserCircle from "~icons/lucide/user-circle";
import { env } from "$env/dynamic/public";
export type AppSidebarItem = { export type AppSidebarItem = {
title: string; title: string;
@@ -47,7 +47,7 @@ export const secondaryNavTree = [
}, },
] as AppSidebarItem[]; ] 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 COMPANY_NAME = "SaaS Template";
export const WEBSITE_URL = "https://company.com"; export const WEBSITE_URL = "https://company.com";

View File

@@ -8,6 +8,7 @@
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import * as Table from "$lib/components/ui/table"; 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 MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { mainNavTree } from "$lib/core/constants"; import { mainNavTree } from "$lib/core/constants";
import { linkVM } from "$lib/domains/link/link.vm.svelte"; import { linkVM } from "$lib/domains/link/link.vm.svelte";
@@ -49,10 +50,18 @@
if (success) resetForm(); 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 { try {
await navigator.clipboard.writeText(token); await navigator.clipboard.writeText(buildPublicLink(token));
toast.success("Token copied to clipboard"); toast.success("Link copied to clipboard");
} catch { } catch {
toast.error("Failed to copy"); toast.error("Failed to copy");
} }
@@ -145,7 +154,7 @@
<button <button
class="text-muted-foreground hover:text-foreground shrink-0" class="text-muted-foreground hover:text-foreground shrink-0"
onclick={() => onclick={() =>
copyToken(link.token)} copyLink(link.token)}
> >
<Copy class="h-3.5 w-3.5" /> <Copy class="h-3.5 w-3.5" />
</button> </button>
@@ -270,7 +279,7 @@
<button <button
class="text-muted-foreground hover:text-foreground" class="text-muted-foreground hover:text-foreground"
onclick={() => onclick={() =>
copyToken(link.token)} copyLink(link.token)}
> >
<Copy class="h-3.5 w-3.5" /> <Copy class="h-3.5 w-3.5" />
</button> </button>

View File

@@ -69,3 +69,18 @@ Update rule:
- Re-condensed timeline from many micro-numbered entries into broader milestone groups. - 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. - 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.

View File

@@ -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 { import {
CreateLink, CreateLink,
Link, Link,
@@ -10,8 +5,13 @@ import {
LinkWithDevice, LinkWithDevice,
UpdateLink, UpdateLink,
} from "./data"; } from "./data";
import { FlowExecCtx } from "@core/flow.execution.context";
import { errAsync, ResultAsync } from "neverthrow";
import { LinkRepository } from "./repository"; import { LinkRepository } from "./repository";
import { type Err } from "@pkg/result";
import { linkErrors } from "./errors"; import { linkErrors } from "./errors";
import { nanoid } from "nanoid";
import { db } from "@pkg/db";
export class LinkController { export class LinkController {
constructor(private repo: LinkRepository) {} constructor(private repo: LinkRepository) {}
@@ -26,9 +26,12 @@ export class LinkController {
/** /**
* Fetch a link by its URL token, including the joined device. * 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<LinkWithDevice, Err> { getByToken(
fctx: FlowExecCtx,
token: string,
): ResultAsync<LinkWithDevice, Err> {
return this.repo.getByToken(fctx, token); return this.repo.getByToken(fctx, token);
} }
@@ -36,7 +39,10 @@ export class LinkController {
* Validate a token: must exist, be active, and not be expired. * Validate a token: must exist, be active, and not be expired.
* Returns the resolved link+device on success. * Returns the resolved link+device on success.
*/ */
validate(fctx: FlowExecCtx, token: string): ResultAsync<LinkWithDevice, Err> { validate(
fctx: FlowExecCtx,
token: string,
): ResultAsync<LinkWithDevice, Err> {
return this.repo.getByToken(fctx, token).andThen((l) => { return this.repo.getByToken(fctx, token).andThen((l) => {
if (l.status !== LinkStatus.ACTIVE) { if (l.status !== LinkStatus.ACTIVE) {
return errAsync(linkErrors.linkNotActive(fctx, token)); return errAsync(linkErrors.linkNotActive(fctx, token));

64
pnpm-lock.yaml generated
View File

@@ -187,70 +187,6 @@ importers:
specifier: ^4.0.15 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) 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: apps/main:
dependencies: dependencies:
'@opentelemetry/api': '@opentelemetry/api':