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}}
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}}

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
- **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/<domain>/` 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 };
```
---

View File

@@ -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

View File

@@ -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",

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_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 { 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,
},

View File

@@ -1,7 +1,81 @@
<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";
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>
<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 { 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";

View File

@@ -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 @@
<button
class="text-muted-foreground hover:text-foreground shrink-0"
onclick={() =>
copyToken(link.token)}
copyLink(link.token)}
>
<Copy class="h-3.5 w-3.5" />
</button>
@@ -270,7 +279,7 @@
<button
class="text-muted-foreground hover:text-foreground"
onclick={() =>
copyToken(link.token)}
copyLink(link.token)}
>
<Copy class="h-3.5 w-3.5" />
</button>

View File

@@ -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.

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 {
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<LinkWithDevice, Err> {
getByToken(
fctx: FlowExecCtx,
token: string,
): ResultAsync<LinkWithDevice, Err> {
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<LinkWithDevice, Err> {
validate(
fctx: FlowExecCtx,
token: string,
): ResultAsync<LinkWithDevice, Err> {
return this.repo.getByToken(fctx, token).andThen((l) => {
if (l.status !== LinkStatus.ACTIVE) {
return errAsync(linkErrors.linkNotActive(fctx, token));

64
pnpm-lock.yaml generated
View File

@@ -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':