feat: add dashboard token auth for android clients
This commit is contained in:
@@ -39,6 +39,10 @@ CODEX_EFFORT=xhigh # Codex reasoning effort
|
|||||||
STATUS_CHANNEL_ID= # Discord channel ID for live status updates
|
STATUS_CHANNEL_ID= # Discord channel ID for live status updates
|
||||||
# STATUS_SHOW_ROOMS=true # Show room status in dashboard
|
# STATUS_SHOW_ROOMS=true # Show room status in dashboard
|
||||||
# STATUS_SHOW_ROOM_DETAILS=false # Show detailed room info
|
# STATUS_SHOW_ROOM_DETAILS=false # Show detailed room info
|
||||||
|
# WEB_DASHBOARD_ENABLED=false # Local dashboard server
|
||||||
|
# WEB_DASHBOARD_HOST=127.0.0.1 # Keep localhost unless using VPN/tunnel
|
||||||
|
# WEB_DASHBOARD_PORT=8734
|
||||||
|
# WEB_DASHBOARD_TOKEN= # Optional bearer token for /api/* when exposing to phone clients
|
||||||
|
|
||||||
# --- Discord image attachments ---
|
# --- Discord image attachments ---
|
||||||
# Extra local directories that agents may attach from. Keep this narrow:
|
# Extra local directories that agents may attach from. Keep this narrow:
|
||||||
|
|||||||
94
docs/android-rayban-display-plan.md
Normal file
94
docs/android-rayban-display-plan.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Android / Meta Ray-Ban Display Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build a personal Android companion for EJClaw that reuses the existing server and dashboard APIs, then extend it to Meta Ray-Ban Display through the Meta Wearables Device Access Toolkit (DAT) when the device developer preview is available to the account.
|
||||||
|
|
||||||
|
## Current Platform Facts
|
||||||
|
|
||||||
|
- EJClaw is a Bun/Node host service. Android should be a thin client, not a port of the runtime.
|
||||||
|
- Meta DAT is in developer preview and provides Android SDK artifacts and sample app guidance through the public `facebook/meta-wearables-dat-android` repo.
|
||||||
|
- DAT preview allows SDK/documentation access and sharing builds with organization/team testers; general public publishing is limited during preview.
|
||||||
|
- Ray-Ban Display currently has two developer paths: Web Apps Dev Mode for HTML/CSS/JS standalone display apps, and DAT Display Developer Preview for extending Android/iOS apps onto the display.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
|
||||||
|
- https://developers.meta.com/blog/introducing-meta-wearables-device-access-toolkit/
|
||||||
|
- https://github.com/facebook/meta-wearables-dat-android
|
||||||
|
- https://www.levinriegner.com/news/l-r-joins-meta-to-open-ray-ban-display-to-developers/
|
||||||
|
|
||||||
|
## MVP Shape
|
||||||
|
|
||||||
|
1. Keep EJClaw running on the existing server.
|
||||||
|
2. Expose the web dashboard only through localhost, VPN, or a private tunnel.
|
||||||
|
3. Require `WEB_DASHBOARD_TOKEN` for `/api/*` before phone clients connect.
|
||||||
|
4. Build an Android app that talks to the existing dashboard API:
|
||||||
|
- `GET /api/overview`
|
||||||
|
- `GET /api/rooms-timeline`
|
||||||
|
- `GET /api/rooms/:jid/timeline`
|
||||||
|
- `POST /api/rooms/:jid/messages`
|
||||||
|
5. Add DAT only for the display surface:
|
||||||
|
- short current-room status
|
||||||
|
- latest assistant output
|
||||||
|
- progress text
|
||||||
|
- quick reply / send command entry
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
Do not expose EJClaw directly to the internet without a private transport.
|
||||||
|
|
||||||
|
Recommended personal setup:
|
||||||
|
|
||||||
|
- `WEB_DASHBOARD_HOST=127.0.0.1` for local-only use, or bind through Tailscale / VPN / SSH tunnel.
|
||||||
|
- Set `WEB_DASHBOARD_TOKEN` and send it from Android as `Authorization: Bearer <token>`.
|
||||||
|
- Keep restart/settings/account routes available only over the same protected API; do not add unauthenticated mobile-only shortcuts.
|
||||||
|
|
||||||
|
## Android App Plan
|
||||||
|
|
||||||
|
Use Kotlin for the first native client. The app can be small:
|
||||||
|
|
||||||
|
- `EJClawApi`: HTTP client with bearer token.
|
||||||
|
- `RoomListViewModel`: fetches `/api/rooms-timeline` on interval.
|
||||||
|
- `RoomThreadViewModel`: fetches room timeline and sends messages.
|
||||||
|
- `DisplayBridge`: DAT integration boundary, initially hidden behind an interface so the app can run without glasses.
|
||||||
|
|
||||||
|
The first build does not need DAT to validate EJClaw connectivity. DAT comes after the phone client can read rooms and send one message.
|
||||||
|
|
||||||
|
## DAT Integration Boundary
|
||||||
|
|
||||||
|
Keep Meta SDK code isolated under an Android module/package such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/android/app/src/main/java/.../display/
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested interfaces:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
interface DisplaySurface {
|
||||||
|
fun showStatus(roomName: String, state: String, progress: String?)
|
||||||
|
fun showMessage(roomName: String, text: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoopDisplaySurface : DisplaySurface { ... }
|
||||||
|
class MetaDatDisplaySurface : DisplaySurface { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
This avoids blocking normal phone testing when DAT access, glasses firmware, region support, or Developer Mode is not ready.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Is the Meta Wearables Developer Center account approved for DAT Display Developer Preview?
|
||||||
|
- Is the target phone able to pair with Ray-Ban Display in the current region/account setup?
|
||||||
|
- Should first Android build be native Kotlin UI or a minimal WebView wrapper around the existing dashboard?
|
||||||
|
- Which private transport will be used: Tailscale, SSH tunnel, Cloudflare Tunnel with access policy, or LAN only?
|
||||||
|
|
||||||
|
## Next Step
|
||||||
|
|
||||||
|
Implement the Android MVP only after the API token guard is merged and deployed. The initial Android client should prove:
|
||||||
|
|
||||||
|
1. Authenticate to EJClaw.
|
||||||
|
2. List rooms.
|
||||||
|
3. Open one room timeline.
|
||||||
|
4. Send one message.
|
||||||
|
5. Run without DAT using `NoopDisplaySurface`.
|
||||||
@@ -46,6 +46,7 @@ describe('config/env loading', () => {
|
|||||||
delete process.env.WEB_DASHBOARD_HOST;
|
delete process.env.WEB_DASHBOARD_HOST;
|
||||||
delete process.env.WEB_DASHBOARD_PORT;
|
delete process.env.WEB_DASHBOARD_PORT;
|
||||||
delete process.env.WEB_DASHBOARD_STATIC_DIR;
|
delete process.env.WEB_DASHBOARD_STATIC_DIR;
|
||||||
|
delete process.env.WEB_DASHBOARD_TOKEN;
|
||||||
delete process.env.SESSION_COMMAND_USER_IDS;
|
delete process.env.SESSION_COMMAND_USER_IDS;
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
@@ -175,12 +176,14 @@ describe('config/env loading', () => {
|
|||||||
expect(config.WEB_DASHBOARD.staticDir).toBe(
|
expect(config.WEB_DASHBOARD.staticDir).toBe(
|
||||||
path.resolve(tempRoot, 'apps', 'dashboard', 'dist'),
|
path.resolve(tempRoot, 'apps', 'dashboard', 'dist'),
|
||||||
);
|
);
|
||||||
|
expect(config.WEB_DASHBOARD.token).toBe('');
|
||||||
|
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
process.env.WEB_DASHBOARD_ENABLED = 'true';
|
process.env.WEB_DASHBOARD_ENABLED = 'true';
|
||||||
process.env.WEB_DASHBOARD_HOST = '0.0.0.0';
|
process.env.WEB_DASHBOARD_HOST = '0.0.0.0';
|
||||||
process.env.WEB_DASHBOARD_PORT = '9001';
|
process.env.WEB_DASHBOARD_PORT = '9001';
|
||||||
process.env.WEB_DASHBOARD_STATIC_DIR = './custom-dashboard-dist';
|
process.env.WEB_DASHBOARD_STATIC_DIR = './custom-dashboard-dist';
|
||||||
|
process.env.WEB_DASHBOARD_TOKEN = 'mobile-secret';
|
||||||
config = await import('./config.js');
|
config = await import('./config.js');
|
||||||
|
|
||||||
expect(config.WEB_DASHBOARD).toEqual({
|
expect(config.WEB_DASHBOARD).toEqual({
|
||||||
@@ -188,6 +191,7 @@ describe('config/env loading', () => {
|
|||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 9001,
|
port: 9001,
|
||||||
staticDir: path.resolve(tempRoot, 'custom-dashboard-dist'),
|
staticDir: path.resolve(tempRoot, 'custom-dashboard-dist'),
|
||||||
|
token: 'mobile-secret',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ export function loadConfig(): AppConfig {
|
|||||||
readNonEmptyText('WEB_DASHBOARD_STATIC_DIR') ??
|
readNonEmptyText('WEB_DASHBOARD_STATIC_DIR') ??
|
||||||
path.join(projectRoot, 'apps', 'dashboard', 'dist'),
|
path.join(projectRoot, 'apps', 'dashboard', 'dist'),
|
||||||
),
|
),
|
||||||
|
token: readNonEmptyText('WEB_DASHBOARD_TOKEN') ?? '',
|
||||||
},
|
},
|
||||||
codexWarmup: {
|
codexWarmup: {
|
||||||
enabled: readBoolean('CODEX_WARMUP_ENABLED', false),
|
enabled: readBoolean('CODEX_WARMUP_ENABLED', false),
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export interface AppConfig {
|
|||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
staticDir: string;
|
staticDir: string;
|
||||||
|
token: string;
|
||||||
};
|
};
|
||||||
codexWarmup: {
|
codexWarmup: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
72
src/web-dashboard-auth.test.ts
Normal file
72
src/web-dashboard-auth.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createWebDashboardHandler } from './web-dashboard-server.js';
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('web dashboard API auth', () => {
|
||||||
|
it('requires a bearer token for API routes when dashboard auth is configured', async () => {
|
||||||
|
const handler = createWebDashboardHandler({
|
||||||
|
authToken: 'mobile-secret',
|
||||||
|
readStatusSnapshots: () => [],
|
||||||
|
getTasks: () => [],
|
||||||
|
getPairedTasks: () => [],
|
||||||
|
startBackgroundCacheRefresh: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const missing = await handler(new Request('http://localhost/api/health'));
|
||||||
|
expect(missing.status).toBe(401);
|
||||||
|
expect(missing.headers.get('www-authenticate')).toBe('Bearer');
|
||||||
|
|
||||||
|
const wrong = await handler(
|
||||||
|
new Request('http://localhost/api/health', {
|
||||||
|
headers: { authorization: 'Bearer wrong-secret' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(wrong.status).toBe(401);
|
||||||
|
|
||||||
|
const ok = await handler(
|
||||||
|
new Request('http://localhost/api/health', {
|
||||||
|
headers: { authorization: 'Bearer mobile-secret' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
await expect(ok.json()).resolves.toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the mobile token header and leaves static assets readable', async () => {
|
||||||
|
const staticDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'ejclaw-dashboard-auth-'),
|
||||||
|
);
|
||||||
|
tempDirs.push(staticDir);
|
||||||
|
fs.writeFileSync(path.join(staticDir, 'index.html'), '<main>ok</main>');
|
||||||
|
const handler = createWebDashboardHandler({
|
||||||
|
authToken: 'mobile-secret',
|
||||||
|
staticDir,
|
||||||
|
readStatusSnapshots: () => [],
|
||||||
|
getTasks: () => [],
|
||||||
|
startBackgroundCacheRefresh: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const asset = await handler(new Request('http://localhost/'));
|
||||||
|
expect(asset.status).toBe(200);
|
||||||
|
await expect(asset.text()).resolves.toContain('<main>ok</main>');
|
||||||
|
|
||||||
|
const api = await handler(
|
||||||
|
new Request('http://localhost/api/health', {
|
||||||
|
headers: { 'x-ejclaw-dashboard-token': 'mobile-secret' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(api.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { execFileSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
|
import { timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
import { WEB_DASHBOARD } from './config.js';
|
import { WEB_DASHBOARD } from './config.js';
|
||||||
import {
|
import {
|
||||||
@@ -67,6 +68,12 @@ const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE =
|
|||||||
const UNIT_NOT_FOUND_PATTERN =
|
const UNIT_NOT_FOUND_PATTERN =
|
||||||
/(Unit .* not found|Could not find the requested service|not-found)/i;
|
/(Unit .* not found|Could not find the requested service|not-found)/i;
|
||||||
|
|
||||||
|
type JsonResponse = (
|
||||||
|
value: unknown,
|
||||||
|
init?: ResponseInit,
|
||||||
|
request?: Request,
|
||||||
|
) => Response;
|
||||||
|
|
||||||
export type { ServiceRestartRecord } from './web-dashboard-service-routes.js';
|
export type { ServiceRestartRecord } from './web-dashboard-service-routes.js';
|
||||||
|
|
||||||
export interface WebDashboardHandlerOptions {
|
export interface WebDashboardHandlerOptions {
|
||||||
@@ -138,6 +145,7 @@ export interface WebDashboardHandlerOptions {
|
|||||||
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
|
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
|
||||||
nudgeScheduler?: () => void;
|
nudgeScheduler?: () => void;
|
||||||
restartServiceStack?: () => string[];
|
restartServiceStack?: () => string[];
|
||||||
|
authToken?: string;
|
||||||
now?: () => string;
|
now?: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +244,43 @@ function serveStaticFile(staticDir: string, pathname: string): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractDashboardAuthToken(request: Request): string {
|
||||||
|
const authorization = request.headers.get('authorization') ?? '';
|
||||||
|
const bearer = authorization.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (bearer) return bearer[1]!.trim();
|
||||||
|
return request.headers.get('x-ejclaw-dashboard-token')?.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeTokenEquals(actual: string, expected: string): boolean {
|
||||||
|
const actualBytes = Buffer.from(actual);
|
||||||
|
const expectedBytes = Buffer.from(expected);
|
||||||
|
if (actualBytes.length !== expectedBytes.length) return false;
|
||||||
|
return timingSafeEqual(actualBytes, expectedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDashboardApiAuthorized(request: Request, token: string): boolean {
|
||||||
|
if (!token) return true;
|
||||||
|
const actual = extractDashboardAuthToken(request);
|
||||||
|
return Boolean(actual) && safeTokenEquals(actual, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectUnauthorizedApi(
|
||||||
|
url: URL,
|
||||||
|
request: Request,
|
||||||
|
token: string,
|
||||||
|
jsonResponse: JsonResponse,
|
||||||
|
): Response | null {
|
||||||
|
if (!url.pathname.startsWith('/api/')) return null;
|
||||||
|
if (isDashboardApiAuthorized(request, token)) return null;
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { 'www-authenticate': 'Bearer' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isRoot(): boolean {
|
function isRoot(): boolean {
|
||||||
return typeof process.getuid === 'function' && process.getuid() === 0;
|
return typeof process.getuid === 'function' && process.getuid() === 0;
|
||||||
}
|
}
|
||||||
@@ -329,7 +374,7 @@ export function createWebDashboardHandler(
|
|||||||
const restartServiceStack =
|
const restartServiceStack =
|
||||||
opts.restartServiceStack ?? restartEjclawStackServices;
|
opts.restartServiceStack ?? restartEjclawStackServices;
|
||||||
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
||||||
|
const authToken = opts.authToken ?? WEB_DASHBOARD.token;
|
||||||
const roomsTimelineDeps: RoomsTimelineRouteDependencies = {
|
const roomsTimelineDeps: RoomsTimelineRouteDependencies = {
|
||||||
statusMaxAgeMs,
|
statusMaxAgeMs,
|
||||||
readSnapshots,
|
readSnapshots,
|
||||||
@@ -342,19 +387,22 @@ export function createWebDashboardHandler(
|
|||||||
loadRecentDeliveredWorkItemsForChat,
|
loadRecentDeliveredWorkItemsForChat,
|
||||||
loadRecentChatMessages,
|
loadRecentChatMessages,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (opts.startBackgroundCacheRefresh !== false) {
|
if (opts.startBackgroundCacheRefresh !== false) {
|
||||||
startRoomsTimelineCacheRefresh(roomsTimelineDeps);
|
startRoomsTimelineCacheRefresh(roomsTimelineDeps);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rememberRoomMessageId = createRoomMessageIdCache();
|
const rememberRoomMessageId = createRoomMessageIdCache();
|
||||||
const inboxDismissTracker = createInboxDismissTracker();
|
const inboxDismissTracker = createInboxDismissTracker();
|
||||||
const recentServiceRestarts: ServiceRestartRecord[] = [];
|
const recentServiceRestarts: ServiceRestartRecord[] = [];
|
||||||
const activeServiceRestartTargets = new Set<string>();
|
const activeServiceRestartTargets = new Set<string>();
|
||||||
|
|
||||||
return async (request: Request): Promise<Response> => {
|
return async (request: Request): Promise<Response> => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
const authFailure = rejectUnauthorizedApi(
|
||||||
|
url,
|
||||||
|
request,
|
||||||
|
authToken,
|
||||||
|
jsonResponse,
|
||||||
|
);
|
||||||
|
if (authFailure) return authFailure;
|
||||||
const scheduledTaskRoute = await handleScheduledTaskRoute({
|
const scheduledTaskRoute = await handleScheduledTaskRoute({
|
||||||
url,
|
url,
|
||||||
request,
|
request,
|
||||||
@@ -368,7 +416,6 @@ export function createWebDashboardHandler(
|
|||||||
now: opts.now,
|
now: opts.now,
|
||||||
});
|
});
|
||||||
if (scheduledTaskRoute) return scheduledTaskRoute;
|
if (scheduledTaskRoute) return scheduledTaskRoute;
|
||||||
|
|
||||||
const inboxActionRoute = await handleInboxActionRoute({
|
const inboxActionRoute = await handleInboxActionRoute({
|
||||||
url,
|
url,
|
||||||
request,
|
request,
|
||||||
@@ -384,7 +431,6 @@ export function createWebDashboardHandler(
|
|||||||
now: opts.now,
|
now: opts.now,
|
||||||
});
|
});
|
||||||
if (inboxActionRoute) return inboxActionRoute;
|
if (inboxActionRoute) return inboxActionRoute;
|
||||||
|
|
||||||
const roomMessageRoute = await handleRoomMessageRoute({
|
const roomMessageRoute = await handleRoomMessageRoute({
|
||||||
url,
|
url,
|
||||||
request,
|
request,
|
||||||
@@ -398,7 +444,6 @@ export function createWebDashboardHandler(
|
|||||||
now: opts.now,
|
now: opts.now,
|
||||||
});
|
});
|
||||||
if (roomMessageRoute) return roomMessageRoute;
|
if (roomMessageRoute) return roomMessageRoute;
|
||||||
|
|
||||||
const roomTimelineRoute = handleRoomTimelineRoute({
|
const roomTimelineRoute = handleRoomTimelineRoute({
|
||||||
url,
|
url,
|
||||||
request,
|
request,
|
||||||
@@ -406,7 +451,6 @@ export function createWebDashboardHandler(
|
|||||||
...roomsTimelineDeps,
|
...roomsTimelineDeps,
|
||||||
});
|
});
|
||||||
if (roomTimelineRoute) return roomTimelineRoute;
|
if (roomTimelineRoute) return roomTimelineRoute;
|
||||||
|
|
||||||
const serviceRoute = await handleServiceRoute({
|
const serviceRoute = await handleServiceRoute({
|
||||||
url,
|
url,
|
||||||
request,
|
request,
|
||||||
@@ -417,18 +461,15 @@ export function createWebDashboardHandler(
|
|||||||
now: opts.now,
|
now: opts.now,
|
||||||
});
|
});
|
||||||
if (serviceRoute) return serviceRoute;
|
if (serviceRoute) return serviceRoute;
|
||||||
|
|
||||||
const settingsRoute = await handleSettingsRoute({
|
const settingsRoute = await handleSettingsRoute({
|
||||||
url,
|
url,
|
||||||
request,
|
request,
|
||||||
jsonResponse,
|
jsonResponse,
|
||||||
});
|
});
|
||||||
if (settingsRoute) return settingsRoute;
|
if (settingsRoute) return settingsRoute;
|
||||||
|
|
||||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const simpleGetRoute = handleSimpleGetRoute({
|
const simpleGetRoute = handleSimpleGetRoute({
|
||||||
url,
|
url,
|
||||||
statusMaxAgeMs,
|
statusMaxAgeMs,
|
||||||
@@ -437,7 +478,6 @@ export function createWebDashboardHandler(
|
|||||||
jsonResponse,
|
jsonResponse,
|
||||||
});
|
});
|
||||||
if (simpleGetRoute) return simpleGetRoute;
|
if (simpleGetRoute) return simpleGetRoute;
|
||||||
|
|
||||||
const overviewRoute = handleOverviewRoute({
|
const overviewRoute = handleOverviewRoute({
|
||||||
url,
|
url,
|
||||||
jsonResponse,
|
jsonResponse,
|
||||||
@@ -450,17 +490,14 @@ export function createWebDashboardHandler(
|
|||||||
now: opts.now,
|
now: opts.now,
|
||||||
});
|
});
|
||||||
if (overviewRoute) return overviewRoute;
|
if (overviewRoute) return overviewRoute;
|
||||||
|
|
||||||
if (url.pathname.startsWith('/api/')) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
return jsonResponse({ error: 'Not found' }, { status: 404 });
|
return jsonResponse({ error: 'Not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!opts.staticDir) {
|
if (!opts.staticDir) {
|
||||||
return new Response('Dashboard static directory is not configured', {
|
return new Response('Dashboard static directory is not configured', {
|
||||||
status: 404,
|
status: 404,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return serveStaticFile(opts.staticDir, url.pathname);
|
return serveStaticFile(opts.staticDir, url.pathname);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,18 +21,18 @@ import {
|
|||||||
resolveWorkspaceInstallCommand,
|
resolveWorkspaceInstallCommand,
|
||||||
} from './workspace-package-manager.js';
|
} from './workspace-package-manager.js';
|
||||||
|
|
||||||
describe('workspace package manager helpers', () => {
|
let tempRoot: string;
|
||||||
let tempRoot: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-pkgmgr-'));
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-pkgmgr-'));
|
||||||
execFileSyncMock.mockReset();
|
execFileSyncMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('workspace package manager detection', () => {
|
||||||
it('detects package managers from packageManager field and lockfiles', () => {
|
it('detects package managers from packageManager field and lockfiles', () => {
|
||||||
const pnpmRepo = path.join(tempRoot, 'pnpm');
|
const pnpmRepo = path.join(tempRoot, 'pnpm');
|
||||||
fs.mkdirSync(pnpmRepo, { recursive: true });
|
fs.mkdirSync(pnpmRepo, { recursive: true });
|
||||||
@@ -110,7 +110,9 @@ describe('workspace package manager helpers', () => {
|
|||||||
commandText: 'bun run build',
|
commandText: 'bun run build',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workspace dependency installs', () => {
|
||||||
it('installs dependencies once and tracks install fingerprint', () => {
|
it('installs dependencies once and tracks install fingerprint', () => {
|
||||||
const repoDir = path.join(tempRoot, 'repo');
|
const repoDir = path.join(tempRoot, 'repo');
|
||||||
fs.mkdirSync(repoDir, { recursive: true });
|
fs.mkdirSync(repoDir, { recursive: true });
|
||||||
@@ -312,7 +314,9 @@ describe('workspace package manager helpers', () => {
|
|||||||
expect(execFileSyncMock).toHaveBeenCalledTimes(2);
|
expect(execFileSyncMock).toHaveBeenCalledTimes(2);
|
||||||
expect(hasInstalledNodeModules(repoDir)).toBe(true);
|
expect(hasInstalledNodeModules(repoDir)).toBe(true);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workspace package manager environment', () => {
|
||||||
it('disables corepack project specs only for lockfile-selected pnpm workspaces under a conflicting ancestor', () => {
|
it('disables corepack project specs only for lockfile-selected pnpm workspaces under a conflicting ancestor', () => {
|
||||||
const parentDir = path.join(tempRoot, 'parent');
|
const parentDir = path.join(tempRoot, 'parent');
|
||||||
fs.mkdirSync(parentDir, { recursive: true });
|
fs.mkdirSync(parentDir, { recursive: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user