From 3e018d60f96294d48cc5776f46ea200d4ed73a43 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 28 Mar 2026 20:10:44 +0200 Subject: [PATCH] poller and release on end to frontend --- apps/frontend/src/lib/domains/link/client.ts | 13 ++- .../src/lib/domains/link/link.remote.ts | 19 +++- apps/frontend/src/lib/domains/link/service.ts | 96 +++++++++++++++++++ apps/frontend/src/routes/session/+page.svelte | 28 ++++-- apps/main/src/lib/domains/link/link.remote.ts | 25 ++++- memory.log.md | 7 ++ packages/logic/domains/link/controller.ts | 21 +++- 7 files changed, 196 insertions(+), 13 deletions(-) diff --git a/apps/frontend/src/lib/domains/link/client.ts b/apps/frontend/src/lib/domains/link/client.ts index 72a1368..373142d 100644 --- a/apps/frontend/src/lib/domains/link/client.ts +++ b/apps/frontend/src/lib/domains/link/client.ts @@ -1,4 +1,4 @@ -import { resolveLinkSQ } from "./link.remote"; +import { checkLinkValiditySQ, resolveLinkSQ } from "./link.remote"; import type { Err } from "@pkg/result"; export type SessionEndReason = @@ -22,6 +22,17 @@ export async function resolveLinkFresh(token: string) { return await query; } +export async function checkLinkValidityFresh(token: string) { + const query = checkLinkValiditySQ({ token }); + + if (query.ready) { + await query.refresh(); + return query.current; + } + + return await query; +} + export function mapLinkErrorToSessionEndReason( error?: Pick | null, ): SessionEndReason { diff --git a/apps/frontend/src/lib/domains/link/link.remote.ts b/apps/frontend/src/lib/domains/link/link.remote.ts index da016e4..cd0d24d 100644 --- a/apps/frontend/src/lib/domains/link/link.remote.ts +++ b/apps/frontend/src/lib/domains/link/link.remote.ts @@ -1,6 +1,11 @@ import { getFlowExecCtxForRemoteFuncs } from "$lib/core/server.utils"; import { command, getRequestEvent, query } from "$app/server"; -import { prepareLinkFlow, resolveLinkFlow } from "./service"; +import { + checkLinkValidityFlow, + endLinkSessionFlow, + prepareLinkFlow, + resolveLinkFlow, +} from "./service"; import { tokenSchema } from "./data"; export const resolveLinkSQ = query(tokenSchema, async ({ token }) => { @@ -14,3 +19,15 @@ export const prepareLinkSC = command(tokenSchema, async ({ token }) => { const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); return prepareLinkFlow(fctx, token); }); + +export const checkLinkValiditySQ = query(tokenSchema, async ({ token }) => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + return checkLinkValidityFlow(fctx, token); +}); + +export const endLinkSessionSC = command(tokenSchema, async ({ token }) => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + return endLinkSessionFlow(fctx, token); +}); diff --git a/apps/frontend/src/lib/domains/link/service.ts b/apps/frontend/src/lib/domains/link/service.ts index ea849b2..a9331ef 100644 --- a/apps/frontend/src/lib/domains/link/service.ts +++ b/apps/frontend/src/lib/domains/link/service.ts @@ -13,8 +13,10 @@ 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"; +import { getDeviceController } from "@pkg/logic/domains/device/controller"; const lc = getLinkController(); +const dc = getDeviceController(); type LinkSessionShape = { id: number; @@ -243,3 +245,97 @@ export async function prepareLinkFlow(fctx: FlowExecCtx, token: string) { }; } } + +export async function checkLinkValidityFlow(fctx: FlowExecCtx, token: string) { + logDomainEvent({ + event: "front.link_validity_check.started", + fctx, + meta: { token }, + }); + + const linkResult = await lc.validateReadOnly(fctx, token); + if (linkResult.isErr()) { + logDomainEvent({ + level: "warn", + event: "front.link_validity_check.rejected", + fctx, + error: linkResult.error, + meta: { token }, + }); + + return { data: null, error: linkResult.error }; + } + + const link = linkResult.value; + const streamUrl = link.device + ? buildStreamUrl(link.device.host, link.device.wsPort) + : null; + + return { + data: { + link: { + id: link.id, + token: link.token, + status: link.status, + expiresAt: link.expiresAt, + }, + device: link.device + ? { + id: link.device.id, + streamUrl, + } + : null, + }, + error: null, + }; +} + +export async function endLinkSessionFlow(fctx: FlowExecCtx, token: string) { + logDomainEvent({ + event: "front.link_session_end.started", + fctx, + meta: { token }, + }); + + const linkResult = await lc.getByToken(fctx, token); + if (linkResult.isErr()) { + logDomainEvent({ + level: "warn", + event: "front.link_session_end.link_lookup_failed", + fctx, + error: linkResult.error, + meta: { token }, + }); + + return { data: null, error: linkResult.error }; + } + + const linkedDeviceId = linkResult.value.linkedDeviceId; + if (linkedDeviceId === null) { + return { + data: { released: false, reason: "no_linked_device" as const }, + error: null, + }; + } + + const releaseResult = await dc.release(fctx, linkedDeviceId); + if (releaseResult.isErr()) { + logDomainEvent({ + level: "error", + event: "front.link_session_end.release_failed", + fctx, + error: releaseResult.error, + meta: { + token, + deviceId: linkedDeviceId, + }, + }); + + return { data: null, error: releaseResult.error }; + } + + return { + data: { released: true, deviceId: linkedDeviceId }, + error: null, + }; +} diff --git a/apps/frontend/src/routes/session/+page.svelte b/apps/frontend/src/routes/session/+page.svelte index 5788f65..b266682 100644 --- a/apps/frontend/src/routes/session/+page.svelte +++ b/apps/frontend/src/routes/session/+page.svelte @@ -1,9 +1,10 @@ diff --git a/apps/main/src/lib/domains/link/link.remote.ts b/apps/main/src/lib/domains/link/link.remote.ts index 981bad7..344df7f 100644 --- a/apps/main/src/lib/domains/link/link.remote.ts +++ b/apps/main/src/lib/domains/link/link.remote.ts @@ -1,10 +1,12 @@ import { getLinkController } from "@pkg/logic/domains/link/controller"; import { createLinkSchema, updateLinkSchema } from "@pkg/logic/domains/link/data"; +import { getDeviceController } from "@pkg/logic/domains/device/controller"; import { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils"; import { command, getRequestEvent, query } from "$app/server"; import * as v from "valibot"; const lc = getLinkController(); +const dc = getDeviceController(); export const listLinksSQ = query(async () => { const event = getRequestEvent(); @@ -66,10 +68,25 @@ export const revokeLinkSC = command( const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); if (!fctx.userId) return unauthorized(fctx); - const res = await lc.revoke(fctx, payload.id); - return res.isOk() - ? { data: res.value, error: null } - : { data: null, error: res.error }; + const linkResult = await lc.getById(fctx, payload.id); + if (linkResult.isErr()) { + return { data: null, error: linkResult.error }; + } + + const revokeResult = await lc.revoke(fctx, payload.id); + if (revokeResult.isErr()) { + return { data: null, error: revokeResult.error }; + } + + const linkedDeviceId = linkResult.value.linkedDeviceId; + if (linkedDeviceId !== null) { + const releaseResult = await dc.release(fctx, linkedDeviceId); + if (releaseResult.isErr()) { + return { data: null, error: releaseResult.error }; + } + } + + return { data: revokeResult.value, error: null }; }, ); diff --git a/memory.log.md b/memory.log.md index f4b982e..322f9b2 100644 --- a/memory.log.md +++ b/memory.log.md @@ -95,3 +95,10 @@ Update rule: - Added `adb` installation to `dockerfiles/orchestrator.Dockerfile` so session-prepare no longer fails with `spawn adb ENOENT` in production containers. - Kept runtime behavior unchanged otherwise; this is strictly a missing binary/runtime dependency fix. + +### 14 — Session Safety: No-Touch Polling + Release Hooks + +- Added read-only link validation in logic (`validateReadOnly`) and wired a dedicated frontend validity query for high-frequency polling without updating `lastAccessedAt`. +- Updated frontend session polling to use the new no-touch validity path and avoid stale query cache via forced refresh behavior. +- Added frontend session end command to explicitly release the linked device on session teardown/ejection. +- Updated admin link revoke flow to release the linked device after revocation so revoked sessions do not leave devices stuck `inUse`. diff --git a/packages/logic/domains/link/controller.ts b/packages/logic/domains/link/controller.ts index 9ea46dd..282b4e2 100644 --- a/packages/logic/domains/link/controller.ts +++ b/packages/logic/domains/link/controller.ts @@ -6,7 +6,7 @@ import { UpdateLink, } from "./data"; import { FlowExecCtx } from "@core/flow.execution.context"; -import { errAsync, ResultAsync } from "neverthrow"; +import { errAsync, okAsync, ResultAsync } from "neverthrow"; import { LinkRepository } from "./repository"; import { type Err } from "@pkg/result"; import { linkErrors } from "./errors"; @@ -54,6 +54,25 @@ export class LinkController { }); } + /** + * Validate a token without updating lastAccessedAt. + * Useful for high-frequency polling paths. + */ + validateReadOnly( + fctx: FlowExecCtx, + token: string, + ): ResultAsync { + return this.repo.getByToken(fctx, token).andThen((l) => { + if (l.status !== LinkStatus.ACTIVE) { + return errAsync(linkErrors.linkNotActive(fctx, token)); + } + if (l.expiresAt && l.expiresAt < new Date()) { + return errAsync(linkErrors.linkExpired(fctx, token)); + } + return okAsync(l); + }); + } + /** * Generate a new link. Token is auto-generated as a URL-safe nanoid. */