Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ The hosted dashboard supports the delivered protocol-1 management surface:

Backend authorization remains authoritative. Client-side role and status checks only shape the interface and never replace backend guards.

Hosted cookie authentication requires a browser that supports the backend's CHIPS `Partitioned` cookies and the Web Locks API. The PWA refuses unsafe refresh behavior when Web Locks are unavailable. This is the current supported hosted-auth contract; CHIPS and Web Locks are not universal across browsers. The backend must use exactly `CORS_ORIGIN=https://app.minepanel.xyz` for the hosted deployment. Same-origin deployment remains the broadest compatibility option.
GitHub OAuth is optional when/if advertised by backend capability and is not a Phase 1.5 completion blocker. The PKCE authorization-code fallback is conditional future compatibility work only if full hosted cross-origin browser compatibility remains a product requirement; it is not implemented today.
Hosted cookie authentication requires a secure browser context with Web Locks and a browser that supports the backend's CHIPS `Partitioned` cookies. The PWA refuses unsafe refresh behavior when Web Locks are unavailable; it relies on the backend's capability advertisement and cannot independently prove HttpOnly partitioned-cookie behavior for every backend origin. This is the current supported hosted-auth contract; CHIPS and Web Locks are not universal across browsers. The backend must use exactly `CORS_ORIGIN=https://app.minepanel.xyz` for the hosted deployment. Same-origin deployment remains the broadest compatibility option. See the public [`/compatibility`](https://app.minepanel.xyz/compatibility) guide for the browser and hosted-auth boundary.
GitHub OAuth is optional future identity work and is not a Phase 1.5 completion blocker. No token-based hosted-auth fallback is implemented for unsupported browser environments.

Google sign-in for accounts with enabled TOTP is not supported by the current backend contract: provider login returns `TwoFactorAuthenticationRequired` without a pre-auth challenge. Use password sign-in for those accounts until the backend supplies a constrained provider 2FA continuation.

Expand Down
27 changes: 17 additions & 10 deletions roadmap.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"updatedAt": "2026-08-27",
"updatedAt": "2026-09-02",
"phases": [
{
"id": "1",
Expand Down Expand Up @@ -45,7 +45,7 @@
"label": "Phase 1.5",
"name": "Hosted Authentication",
"status": "completed",
"description": "The supported protocol-1 hosted-auth contract is complete: CHIPS partitioned HttpOnly cookies + Web Locks for hosted sessions, with local auth, Google auth, TOTP, account security, and first-admin setup. Optional GitHub OAuth and conditional PKCE compatibility work remain separate future concerns; CHIPS/Web Locks are not universal across browsers.",
"description": "The supported protocol-1 hosted-auth contract is complete: CHIPS partitioned HttpOnly cookies + Web Locks for hosted sessions, with local auth, Google auth, TOTP, account security, and first-admin setup. CHIPS/Web Locks are not universal across browsers.",
"progress": 100,
"progressLabel": "100% complete",
"items": [
Expand Down Expand Up @@ -76,14 +76,6 @@
{
"text": "First-admin setup",
"done": true
},
{
"text": "[Optional] GitHub OAuth when/if advertised by backend capability",
"done": false
},
{
"text": "[Conditional] PKCE authorization-code fallback if full hosted-browser compatibility remains required",
"done": false
}
]
},
Expand Down Expand Up @@ -156,6 +148,21 @@
"done": false
}
]
},
{
"id": "future-identity-authentication",
"label": "Future Identity / Authentication",
"name": "Optional Follow-ons",
"description": "Optional identity integrations that remain outside the completed hosted-authentication scope.",
"status": "future",
"progress": 0,
"progressLabel": null,
"items": [
{
"text": "[Optional] GitHub OAuth identity integration",
"done": false
}
]
}
],
"scope": "scope: MinePanel hosted dashboard (minepanel-pwa). This file is the PWA roadmap; it does not redefine the global backend project roadmap."
Expand Down
10 changes: 10 additions & 0 deletions src/app/panel-route.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ let root: Root | null = null;
let container: HTMLDivElement | null = null;

beforeEach(async () => {
Object.defineProperty(globalThis, 'isSecureContext', { configurable: true, value: true });
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: {
Expand Down Expand Up @@ -57,6 +58,7 @@ afterEach(async () => {
await db.clear('instances');
db.close();
vi.unstubAllGlobals();
Object.defineProperty(globalThis, 'isSecureContext', { configurable: true, value: undefined });
Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined });
});

Expand Down Expand Up @@ -377,4 +379,12 @@ describe('panel hard remount (Finding 2 regression)', () => {
expect(container?.textContent).not.toContain('AdminA');
expect(await waitForText('UserB')).toBe(true);
});
});

describe('public compatibility route', () => {
it('renders without a saved or connected panel', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await mountApplicationRouter(queryClient, '/compatibility');
expect(await waitForText('MinePanel compatibility')).toBe(true);
});
});
2 changes: 2 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate } from 'react-router';
import { AppFrame } from '@/components/app-frame';
import { PanelRoute } from '@/components/panel-route';
import { AddPanelPage } from '@/pages/add-panel-page';
import { CompatibilityPage } from '@/pages/compatibility-page';
import { HomePage } from '@/pages/home-page';
import { AccountPage } from '@/pages/panel/account-page';
import { AdminUserDetailPage } from '@/pages/panel/admin/user-detail-page';
Expand All @@ -19,6 +20,7 @@ export const router = createBrowserRouter([
Component: AppFrame,
children: [
{ index: true, Component: HomePage },
{ path: 'compatibility', Component: CompatibilityPage },
{ path: 'add', Component: AddPanelPage },
],
},
Expand Down
18 changes: 17 additions & 1 deletion src/auth/panel-session-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ import type { PanelInfo } from '@/api/types';
import type { InstanceIdentity } from '@/api/query-keys';
import type { SessionController } from './session-controller';

export type BrowserIncompatibilityReason =
| 'insecure-context'
| 'web-locks-unavailable';

export type PanelIncompatibilityReason =
| 'unsupported-protocol'
| 'partitioned-auth-not-advertised';

export type SessionProblem =
| { kind: 'offline' }
| { kind: 'expired' }
| { kind: 'incompatible'; subject: 'browser'; reason: BrowserIncompatibilityReason }
| { kind: 'incompatible'; subject: 'panel'; reason: PanelIncompatibilityReason }
| { kind: 'hosted-origin-forbidden' }
| { kind: 'session-restore-failed' };

export type ShellSession =
| { kind: 'loading' }
| { kind: 'anonymous' }
Expand All @@ -12,7 +28,7 @@ export type ShellSession =
| { kind: 'password-change-required' }
| { kind: 'account-pending' }
| { kind: 'account-banned' }
| { kind: 'error'; reason: 'offline' | 'incompatible' | 'expired' };
| { kind: 'error'; problem: SessionProblem };

export type PanelSessionValue = {
panel: InstanceIdentity | null;
Expand Down
65 changes: 58 additions & 7 deletions src/auth/session-controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { BackendClient } from '@/api/backend-client';
import type { AuthProfile, PanelInfo } from '@/api/types';
import { BackendApiError } from '@/api/errors';
import { BackendApiError, BackendClientError } from '@/api/errors';
import type { ShellSession } from './panel-session-context';
import { resetRefreshBrokerForTests } from './refresh-broker';
import { SessionController } from './session-controller';
Expand All @@ -10,11 +10,12 @@ const info: PanelInfo = { name: 'MinePanel', version: '1', api: { protocolVersio
const profile: AuthProfile = { id: 'user-a', username: 'alex', role: 'USER' };

const installLocks = (): void => {
Object.defineProperty(globalThis, 'isSecureContext', { configurable: true, value: true });
Object.defineProperty(navigator, 'locks', { configurable: true, value: { request: async <T>(_name: string, _options: { mode: 'exclusive' }, callback: () => Promise<T>) => callback() } });
};

afterEach(() => {
resetRefreshBrokerForTests();
Object.defineProperty(globalThis, 'isSecureContext', { configurable: true, value: undefined });
Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined });
});

Expand Down Expand Up @@ -68,11 +69,61 @@ describe('SessionController', () => {
});

it('requires a compatible browser lock before hosted-cookie authentication', async () => {
Object.defineProperty(globalThis, 'isSecureContext', { configurable: true, value: true });
const client = { origin: 'https://panel.example', getInfo: vi.fn().mockResolvedValue(info), getProfile: vi.fn() };
const { controller, states } = controllerFor(client);
await controller.start();
expect(client.getProfile).not.toHaveBeenCalled();
expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'incompatible', subject: 'browser', reason: 'web-locks-unavailable' } });
});

it('blocks an insecure hosted context before restoring a session', async () => {
installLocks();
Object.defineProperty(globalThis, 'isSecureContext', { configurable: true, value: false });
const client = { origin: 'https://panel.example', getInfo: vi.fn().mockResolvedValue(info), getProfile: vi.fn() };
const { controller, states } = controllerFor(client);
await controller.start();
expect(client.getProfile).not.toHaveBeenCalled();
expect(states.at(-1)).toEqual({ kind: 'error', reason: 'incompatible' });
expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'incompatible', subject: 'browser', reason: 'insecure-context' } });
});

it('distinguishes unsupported panel protocol and hosted-auth advertisement', async () => {
installLocks();
const protocolInfo = { ...info, api: { protocolVersion: 2 } };
const client = { origin: 'https://panel.example', getInfo: vi.fn().mockResolvedValue(protocolInfo), getProfile: vi.fn() };
const { controller, states } = controllerFor(client);
await controller.start();
expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'incompatible', subject: 'panel', reason: 'unsupported-protocol' } });

client.getInfo.mockResolvedValue({ ...info, capabilities: { ...info.capabilities, auth: { ...info.capabilities.auth, partitionedCookies: false } } });
await controller.start();
expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'incompatible', subject: 'panel', reason: 'partitioned-auth-not-advertised' } });
expect(client.getProfile).not.toHaveBeenCalled();
});

it('keeps unavailable panels separate from compatibility failures', async () => {
installLocks();
const client = { origin: 'https://panel.example', getInfo: vi.fn().mockRejectedValue(new BackendClientError('unavailable', 'offline')), getProfile: vi.fn() };
const { controller, states } = controllerFor(client);
await controller.start();
expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'offline' } });
});
it('retries with fresh panel compatibility information', async () => {
installLocks();
const client = {
origin: 'https://panel.example',
getInfo: vi.fn()
.mockResolvedValueOnce({ ...info, api: { protocolVersion: 2 } })
.mockResolvedValueOnce(info),
getProfile: vi.fn().mockResolvedValue(profile),
};
const { controller, states } = controllerFor(client);
await controller.start();
expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'incompatible', subject: 'panel', reason: 'unsupported-protocol' } });
controller.retryRestore();
await flush();
expect(client.getInfo).toHaveBeenCalledTimes(2);
expect(states.at(-1)).toEqual({ kind: 'authenticated', profile: { id: 'user-a', username: 'alex', role: 'USER' } });
});
});

Expand Down Expand Up @@ -168,7 +219,7 @@ describe('expired-session retry (BLOCKER-3 regression)', () => {

controller.retryRestore();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(states.at(-1)).toEqual({ kind: 'error', reason: 'expired' });
expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'expired' } });

// The retry action must now resolve to anonymous instead of looping.
controller.retryRestore();
Expand All @@ -193,7 +244,7 @@ describe('terminal refresh boundary (Finding 1 regression)', () => {
// Simulate the client coordinator discovering a terminally invalid
// refresh session on an arbitrary authenticated endpoint.
controller.handleTerminalRefresh();
expect(states.at(-1)).toEqual({ kind: 'error', reason: 'expired' });
expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'expired' } });
expect(onBoundary).toHaveBeenCalledOnce();

// Idempotent: repeated terminal notifications must not re-run boundary.
Expand Down Expand Up @@ -230,12 +281,12 @@ describe('session authority race (MP-BASELINE-SESSION-001)', () => {
mockableClient.getProfile = vi.fn(() => deferred.promise);
void controller.restoreProfile();
controller.handleTerminalRefresh();
expect(states.at(-1)).toEqual({ kind: 'error', reason: 'expired' });
expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'expired' } });

deferred.resolve(profile);
await flush();
await flush();
expect(states.at(-1)).toEqual({ kind: 'error', reason: 'expired' });
expect(states.at(-1)).toEqual({ kind: 'error', problem: { kind: 'expired' } });
});

it('whole-session clear cannot be overwritten by a stale profile restore', async () => {
Expand Down
63 changes: 49 additions & 14 deletions src/auth/session-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import type { BackendClient, PanelInfo } from '@/api/backend-client';
import { BackendApiError, BackendClientError } from '@/api/errors';
import type { AuthProfile, LoginInput, PublicUser, RegisterInput } from '@/api/types';
import { hasRefreshLockSupport, refreshWithBroker } from './refresh-broker';
import type { ShellSession } from './panel-session-context';
import type {
BrowserIncompatibilityReason,
PanelIncompatibilityReason,
SessionProblem,
ShellSession,
} from './panel-session-context';

export type SessionControllerOptions = {
client: BackendClient;
Expand Down Expand Up @@ -30,8 +35,22 @@ const profileForShell = (profile: AuthProfile): Extract<ShellSession, { kind: 'a
},
});

const isCapabilityCompatible = (info: PanelInfo): boolean =>
info.api.protocolVersion === 1 && info.capabilities.auth.partitionedCookies;
const browserIncompatibility = (): BrowserIncompatibilityReason | null => {
if (globalThis.isSecureContext !== true) return 'insecure-context';
if (!hasRefreshLockSupport()) return 'web-locks-unavailable';
return null;
};

const panelIncompatibility = (info: PanelInfo): PanelIncompatibilityReason | null => {
if (info.api.protocolVersion !== 1) return 'unsupported-protocol';
if (!info.capabilities.auth.partitionedCookies) return 'partitioned-auth-not-advertised';
return null;
};

const incompatibleProblem = (reason: BrowserIncompatibilityReason | PanelIncompatibilityReason): SessionProblem =>
reason === 'insecure-context' || reason === 'web-locks-unavailable'
? { kind: 'incompatible', subject: 'browser', reason }
: { kind: 'incompatible', subject: 'panel', reason };

const isApiCode = (error: unknown, code: string): boolean =>
error instanceof BackendApiError && error.code === code;
Expand Down Expand Up @@ -81,7 +100,7 @@ export class SessionController {
this.invalidateOperations();
this.hadAuthenticatedSession = false;
this.options.onBoundary();
this.setState({ kind: 'error', reason: 'expired' });
this.setState({ kind: 'error', problem: { kind: 'expired' } });
}
}

Expand All @@ -102,23 +121,32 @@ export class SessionController {

async start(): Promise<void> {
this.disposed = false;
const wasAuthenticated = this.state.kind === 'authenticated';
const operation = ++this.operation;
this.setState({ kind: 'loading' });
try {
const info = await this.options.client.getInfo();
if (!this.isCurrent(operation)) return;
this.info = info;
this.options.onInfo(info, null);
if (!isCapabilityCompatible(info) || !hasRefreshLockSupport()) {
this.setState({ kind: 'error', reason: 'incompatible' });
const issue = browserIncompatibility() ?? panelIncompatibility(info);
if (issue !== null) {
if (wasAuthenticated) {
this.hadAuthenticatedSession = false;
this.options.onBoundary();
}
this.setState({ kind: 'error', problem: incompatibleProblem(issue) });
return;
}
await this.restore(operation);
} catch (error) {
if (!this.isCurrent(operation)) return;
this.info = null;
this.options.onInfo(null, error);
this.setState({ kind: 'error', reason: isUnavailable(error) ? 'offline' : 'incompatible' });
this.setState({
kind: 'error',
problem: isUnavailable(error) ? { kind: 'offline' } : { kind: 'session-restore-failed' },
});
}
}

Expand Down Expand Up @@ -255,7 +283,7 @@ export class SessionController {
const wasAuthenticated = this.hadAuthenticatedSession;
this.hadAuthenticatedSession = false;
this.options.onBoundary();
this.setState(wasAuthenticated ? { kind: 'error', reason: 'expired' } : { kind: 'anonymous' });
this.setState(wasAuthenticated ? { kind: 'error', problem: { kind: 'expired' } } : { kind: 'anonymous' });
return;
}
this.mapTerminalError(error, true);
Expand All @@ -281,26 +309,33 @@ export class SessionController {
this.setState({ kind: 'account-banned' });
return;
}
if (isApiCode(error, 'CsrfOriginForbidden') || error instanceof BackendClientError && error.kind === 'invalid-response') {
if (isApiCode(error, 'CsrfOriginForbidden')) {
this.invalidateOperations();
this.options.onBoundary();
this.setState({ kind: 'error', problem: { kind: 'hosted-origin-forbidden' } });
return;
}
if (error instanceof BackendClientError && error.kind === 'invalid-response') {
this.invalidateOperations();
this.options.onBoundary();
this.setState({ kind: 'error', reason: 'incompatible' });
this.setState({ kind: 'error', problem: { kind: 'session-restore-failed' } });
return;
}
if (restoring && (isUnavailable(error) || error instanceof BackendApiError && error.status >= 500)) {
this.setState({ kind: 'error', reason: 'offline' });
this.setState({ kind: 'error', problem: { kind: 'offline' } });
return;
}
if (restoring) {
this.invalidateOperations();
this.options.onBoundary();
this.setState({ kind: 'error', reason: 'incompatible' });
this.setState({ kind: 'error', problem: { kind: 'session-restore-failed' } });
}
}

private ensureCompatible(): void {
if (this.info === null || !isCapabilityCompatible(this.info) || !hasRefreshLockSupport()) {
this.setState({ kind: 'error', reason: 'incompatible' });
const issue = this.info === null ? null : browserIncompatibility() ?? panelIncompatibility(this.info);
if (issue !== null) {
this.setState({ kind: 'error', problem: incompatibleProblem(issue) });
throw new Error('This panel cannot safely use hosted cookie authentication in this browser.');
}
}
Expand Down
Loading