fix(auth): unify imported sessions with account gating
This commit is contained in:
1 parent
654bbda457
commit
ea92a1a92b
15 files changed
+121
-47
No files matched your search
@@ -16,11 +16,15 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
|
|||||||
return (
|
return (
|
||||||
<article className="media-card">
|
<article className="media-card">
|
||||||
<div className="media-preview">
|
<div className="media-preview">
|
||||||
{isRevealed ? (
|
<button
|
||||||
<button aria-label={`View media from ${item.sender} larger`} className="media-preview-button" onClick={onOpenPreview} type="button">
|
aria-label={`View media from ${item.sender} larger`}
|
||||||
<img alt={`Media from ${item.sender}`} className="media-image" loading="lazy" src={item.urlPath} />
|
className="media-preview-button"
|
||||||
|
disabled={!isRevealed}
|
||||||
|
onClick={onOpenPreview}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img alt={`Media from ${item.sender}`} className={isRevealed ? 'media-image' : 'media-image is-blurred'} loading="lazy" src={item.urlPath} />
|
||||||
</button>
|
</button>
|
||||||
) : <img alt={`Media from ${item.sender}`} className="media-image is-blurred" loading="lazy" src={item.urlPath} />}
|
|
||||||
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
|
{!isRevealed ? <span className="privacy-label">Private media hidden</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="media-card-body">
|
<div className="media-card-body">
|
||||||
|
|||||||
+37
-14
@@ -37,6 +37,7 @@ import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
|
|||||||
import { WebServer } from './web/server.js';
|
import { WebServer } from './web/server.js';
|
||||||
import { reportReply } from './web/event-reporting.js';
|
import { reportReply } from './web/event-reporting.js';
|
||||||
import { RedditSessionImporter } from './services/reddit-session-importer.js';
|
import { RedditSessionImporter } from './services/reddit-session-importer.js';
|
||||||
|
import { AccountAuthContext } from './services/account-auth-context.js';
|
||||||
|
|
||||||
export interface AppContainer {
|
export interface AppContainer {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -44,6 +45,7 @@ export interface AppContainer {
|
|||||||
db: Database.Database;
|
db: Database.Database;
|
||||||
|
|
||||||
browserClient: BrowserClient;
|
browserClient: BrowserClient;
|
||||||
|
accountAuth: AccountAuthContext;
|
||||||
|
|
||||||
postsRepo: PostsRepository;
|
postsRepo: PostsRepository;
|
||||||
messagesRepo: MessagesRepository;
|
messagesRepo: MessagesRepository;
|
||||||
@@ -102,23 +104,17 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
|
|
||||||
// Browser
|
// Browser
|
||||||
const browserClient = new BrowserClient(config, logger);
|
const browserClient = new BrowserClient(config, logger);
|
||||||
|
const accountAuth = new AccountAuthContext(logger);
|
||||||
await browserClient.initialize();
|
await browserClient.initialize();
|
||||||
|
|
||||||
// The extension import is the only session acquisition path. A missing
|
// The extension import is the only session acquisition path. A missing
|
||||||
// session is recoverable: the dashboard remains available while all
|
// session is recoverable: the dashboard remains available while all
|
||||||
// account-bound work waits for a verified dump.
|
// account-bound work waits for a verified dump.
|
||||||
logger.info('Verifying Reddit login status...');
|
logger.info('Verifying Reddit login status...');
|
||||||
const loggedIn = await browserClient.isSessionValid();
|
const redditLoggedIn = await browserClient.isSessionValid();
|
||||||
if (!loggedIn) {
|
if (!redditLoggedIn) {
|
||||||
logger.warn('Reddit session is logged out. Account work is paused until a session dump is imported from the extension.');
|
logger.warn('Reddit session is logged out. Account work is paused until a session dump is imported from the extension.');
|
||||||
}
|
}
|
||||||
if (loggedIn) {
|
|
||||||
logger.info('Reddit login verified');
|
|
||||||
}
|
|
||||||
broadcaster.dashboardEvents.redditSession({
|
|
||||||
state: loggedIn ? 'authenticated' : 'login-required',
|
|
||||||
username: loggedIn ? await browserClient.getAuthenticatedUsername() ?? undefined : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reddit API client (shares browser context for auth)
|
// Reddit API client (shares browser context for auth)
|
||||||
const redditApi = new RedditApiClient(config, logger);
|
const redditApi = new RedditApiClient(config, logger);
|
||||||
@@ -143,6 +139,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
successfulMessagesRepo,
|
successfulMessagesRepo,
|
||||||
postsRepo,
|
postsRepo,
|
||||||
logger,
|
logger,
|
||||||
|
accountAuth,
|
||||||
conversationOutcomesRepo,
|
conversationOutcomesRepo,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -161,11 +158,28 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
capturedAt: Date.now(),
|
capturedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const matrixClient = new MatrixClient(matrixCreds, logger);
|
let activeMatrixClient = new MatrixClient(matrixCreds, logger);
|
||||||
|
accountAuth.setAuthenticator(async () => {
|
||||||
|
const redditUsername = await browserClient.getAuthenticatedUsername();
|
||||||
|
if (!redditUsername) return null;
|
||||||
|
try {
|
||||||
|
const matrixUserId = await activeMatrixClient.whoami();
|
||||||
|
if (!matrixUserId) return null;
|
||||||
|
return { accountId: matrixUserId, redditUsername, matrixUserId };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const fullyAuthenticated = redditLoggedIn && await accountAuth.ensureFullyAuthenticated();
|
||||||
|
if (fullyAuthenticated) logger.info('Reddit and Matrix authentication verified');
|
||||||
|
broadcaster.dashboardEvents.redditSession({
|
||||||
|
state: fullyAuthenticated ? 'authenticated' : 'login-required',
|
||||||
|
username: fullyAuthenticated ? accountAuth.getSnapshot().redditUsername ?? undefined : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Build the media downloader + service on top of the Matrix client so
|
// 2. Build the media downloader + service on top of the Matrix client so
|
||||||
// `mxc://` URIs resolve through the same auth + rate-limiter pipeline.
|
// `mxc://` URIs resolve through the same auth + rate-limiter pipeline.
|
||||||
const mediaDownloader = new MatrixMediaDownloader(matrixClient, logger);
|
const mediaDownloader = new MatrixMediaDownloader(activeMatrixClient, logger);
|
||||||
const mediaService = new MediaService(mediaDownloader, logger);
|
const mediaService = new MediaService(mediaDownloader, logger);
|
||||||
|
|
||||||
// A 401 waits for a new extension dump instead of opening a browser login
|
// A 401 waits for a new extension dump instead of opening a browser login
|
||||||
@@ -204,7 +218,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
};
|
};
|
||||||
|
|
||||||
const chatService = new ChatService(
|
const chatService = new ChatService(
|
||||||
matrixClient,
|
activeMatrixClient,
|
||||||
logger,
|
logger,
|
||||||
successfulMessagesRepo,
|
successfulMessagesRepo,
|
||||||
mediaService,
|
mediaService,
|
||||||
@@ -213,9 +227,10 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
undefined,
|
undefined,
|
||||||
messagesRepo,
|
messagesRepo,
|
||||||
redditUsersRepo,
|
redditUsersRepo,
|
||||||
|
accountAuth,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs);
|
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs, accountAuth);
|
||||||
const rateLimiter = new RateLimiter(config, logger, undefined, {
|
const rateLimiter = new RateLimiter(config, logger, undefined, {
|
||||||
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
|
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
|
||||||
});
|
});
|
||||||
@@ -225,9 +240,15 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
|
|
||||||
const sessionImporter = new RedditSessionImporter(
|
const sessionImporter = new RedditSessionImporter(
|
||||||
browserClient,
|
browserClient,
|
||||||
chatService,
|
{
|
||||||
|
setMatrixClient(client) {
|
||||||
|
activeMatrixClient = client;
|
||||||
|
chatService.setMatrixClient(client);
|
||||||
|
},
|
||||||
|
},
|
||||||
(credentials) => new MatrixClient(credentials, logger),
|
(credentials) => new MatrixClient(credentials, logger),
|
||||||
saveCredentials,
|
saveCredentials,
|
||||||
|
accountAuth,
|
||||||
);
|
);
|
||||||
|
|
||||||
let webServer: WebServer | null = null;
|
let webServer: WebServer | null = null;
|
||||||
@@ -296,6 +317,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
webServer.setStrategySelector(strategySelector);
|
webServer.setStrategySelector(strategySelector);
|
||||||
webServer.setBrowserClient(browserClient);
|
webServer.setBrowserClient(browserClient);
|
||||||
webServer.setSessionImporter(sessionImporter);
|
webServer.setSessionImporter(sessionImporter);
|
||||||
|
webServer.setAccountAuth(accountAuth);
|
||||||
webServer.setConversationStateRepository(conversationStateRepo);
|
webServer.setConversationStateRepository(conversationStateRepo);
|
||||||
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
|
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
|
||||||
}
|
}
|
||||||
@@ -316,6 +338,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
logger,
|
logger,
|
||||||
db,
|
db,
|
||||||
browserClient,
|
browserClient,
|
||||||
|
accountAuth,
|
||||||
postsRepo,
|
postsRepo,
|
||||||
messagesRepo,
|
messagesRepo,
|
||||||
successfulMessagesRepo,
|
successfulMessagesRepo,
|
||||||
|
|||||||
+4
-1
@@ -77,6 +77,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
config,
|
config,
|
||||||
logger,
|
logger,
|
||||||
browserClient,
|
browserClient,
|
||||||
|
accountAuth,
|
||||||
postsRepo,
|
postsRepo,
|
||||||
messagesRepo,
|
messagesRepo,
|
||||||
redditApi,
|
redditApi,
|
||||||
@@ -177,7 +178,9 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
|
|
||||||
while (!isShuttingDown) {
|
while (!isShuttingDown) {
|
||||||
try {
|
try {
|
||||||
if (!sessionRuntime.reconcile(browserClient.getSessionState())) {
|
const authenticated = accountAuth.isFullyAuthenticated()
|
||||||
|
|| await accountAuth.ensureFullyAuthenticated();
|
||||||
|
if (!sessionRuntime.reconcile(authenticated)) {
|
||||||
if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) {
|
if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) {
|
||||||
logger.info('Reddit is logged out. Waiting for a session dump from the extension.');
|
logger.info('Reddit is logged out. Waiting for a session dump from the extension.');
|
||||||
lastPausedLog = Date.now();
|
lastPausedLog = Date.now();
|
||||||
|
|||||||
@@ -19,9 +19,8 @@ const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
|
|||||||
|
|
||||||
export interface TokenRefresher {
|
export interface TokenRefresher {
|
||||||
/**
|
/**
|
||||||
* Re-extract a fresh Matrix bearer token (e.g. by re-running the browser
|
* Requests replacement Matrix credentials. Extension-only authentication
|
||||||
* sniff against the persistent session) and apply it to the underlying
|
* deliberately rejects this until the user imports a fresh dump.
|
||||||
* client. Resolves when the client is ready to be retried.
|
|
||||||
*/
|
*/
|
||||||
refresh(): Promise<void>;
|
refresh(): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -175,9 +174,8 @@ export class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a Matrix call; if it 401/403s, re-extract the bearer via the
|
* Run a Matrix call; if it 401/403s, invalidate the shared account gate.
|
||||||
* configured `TokenRefresher` and retry once. Single retry only — if the
|
* The configured refresher reports that a new extension dump is required.
|
||||||
* refresh itself fails, we propagate.
|
|
||||||
*/
|
*/
|
||||||
private async withAuth<T>(fn: () => Promise<T>): Promise<T> {
|
private async withAuth<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
if (!this.accountAuth?.isFullyAuthenticated()) {
|
if (!this.accountAuth?.isFullyAuthenticated()) {
|
||||||
@@ -189,7 +187,7 @@ export class ChatService {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof MatrixAuthError && this.tokenRefresher) {
|
if (err instanceof MatrixAuthError && this.tokenRefresher) {
|
||||||
this.logger.warn('[chat] Matrix token rejected — refreshing via browser session');
|
this.logger.warn('[chat] Matrix token rejected — waiting for a new extension dump');
|
||||||
try {
|
try {
|
||||||
await this.tokenRefresher.refresh();
|
await this.tokenRefresher.refresh();
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { Cookie } from 'playwright';
|
import type { Cookie } from 'playwright';
|
||||||
import type { MatrixClient } from './chat/matrix/client.js';
|
import type { MatrixClient } from './chat/matrix/client.js';
|
||||||
import type { MatrixCredentials } from './chat/matrix/types.js';
|
import type { MatrixCredentials } from './chat/matrix/types.js';
|
||||||
|
import type { AccountAuthContext } from './account-auth-context.js';
|
||||||
|
|
||||||
interface BrowserSessionTarget {
|
interface BrowserSessionTarget {
|
||||||
importRedditCookies(cookies: Cookie[]): Promise<void>;
|
importRedditCookies(cookies: Cookie[]): Promise<void>;
|
||||||
isSessionValid(): Promise<boolean>;
|
isSessionValid(): Promise<boolean>;
|
||||||
|
getAuthenticatedUsername(): Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatSessionTarget {
|
interface ChatSessionTarget {
|
||||||
@@ -20,6 +22,7 @@ export class RedditSessionImporter {
|
|||||||
private readonly chat: ChatSessionTarget,
|
private readonly chat: ChatSessionTarget,
|
||||||
private readonly createMatrixClient: MatrixClientFactory,
|
private readonly createMatrixClient: MatrixClientFactory,
|
||||||
private readonly saveCredentials: CredentialStore,
|
private readonly saveCredentials: CredentialStore,
|
||||||
|
private readonly accountAuth: AccountAuthContext,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async import(cookies: Cookie[], credentials: MatrixCredentials): Promise<void> {
|
async import(cookies: Cookie[], credentials: MatrixCredentials): Promise<void> {
|
||||||
@@ -41,8 +44,15 @@ export class RedditSessionImporter {
|
|||||||
if (!(await this.browser.isSessionValid())) {
|
if (!(await this.browser.isSessionValid())) {
|
||||||
throw new Error('Imported cookies did not authenticate with Reddit');
|
throw new Error('Imported cookies did not authenticate with Reddit');
|
||||||
}
|
}
|
||||||
|
const redditUsername = await this.browser.getAuthenticatedUsername();
|
||||||
|
if (!redditUsername) throw new Error('Reddit did not return an authenticated account identity');
|
||||||
|
|
||||||
this.saveCredentials(credentials);
|
this.saveCredentials(credentials);
|
||||||
this.chat.setMatrixClient(matrixClient);
|
this.chat.setMatrixClient(matrixClient);
|
||||||
|
this.accountAuth.markAuthenticated({
|
||||||
|
accountId: userId,
|
||||||
|
redditUsername,
|
||||||
|
matrixUserId: userId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Logger } from '../utils/logger.js';
|
|||||||
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ConversationOutcomesRepository } from '../database/repositories.js';
|
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ConversationOutcomesRepository } from '../database/repositories.js';
|
||||||
import type { PostsRepository } from '../database/repositories.js';
|
import type { PostsRepository } from '../database/repositories.js';
|
||||||
import { classifyConversationOutcome } from './conversation-outcome.js';
|
import { classifyConversationOutcome } from './conversation-outcome.js';
|
||||||
|
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js';
|
||||||
|
|
||||||
export interface ResponseCheckResult {
|
export interface ResponseCheckResult {
|
||||||
messageId: number;
|
messageId: number;
|
||||||
@@ -18,11 +19,13 @@ export class ResponseCheckerService {
|
|||||||
private successfulMessagesRepo: SuccessfulMessagesRepository,
|
private successfulMessagesRepo: SuccessfulMessagesRepository,
|
||||||
private postsRepo: PostsRepository,
|
private postsRepo: PostsRepository,
|
||||||
private logger: Logger,
|
private logger: Logger,
|
||||||
|
private accountAuth: AccountAuthGate = denyAccountAuth,
|
||||||
private outcomesRepo?: ConversationOutcomesRepository,
|
private outcomesRepo?: ConversationOutcomesRepository,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async checkForResponses(): Promise<ResponseCheckResult[]> {
|
async checkForResponses(): Promise<ResponseCheckResult[]> {
|
||||||
const results: ResponseCheckResult[] = [];
|
const results: ResponseCheckResult[] = [];
|
||||||
|
if (!this.accountAuth.isFullyAuthenticated()) return results;
|
||||||
|
|
||||||
// Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours)
|
// Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours)
|
||||||
const minAgeMs = 60 * 60 * 1000; // 1 hour
|
const minAgeMs = 60 * 60 * 1000; // 1 hour
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { RedditSessionState } from '../browser/client.js';
|
|
||||||
|
|
||||||
/** Starts and stops account-bound work as authentication changes. */
|
/** Starts and stops account-bound work as authentication changes. */
|
||||||
export class SessionBoundRuntime {
|
export class SessionBoundRuntime {
|
||||||
private active = false;
|
private active = false;
|
||||||
@@ -9,8 +7,7 @@ export class SessionBoundRuntime {
|
|||||||
private readonly stop: () => void,
|
private readonly stop: () => void,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
reconcile(state: RedditSessionState): boolean {
|
reconcile(authenticated: boolean): boolean {
|
||||||
const authenticated = state === 'authenticated';
|
|
||||||
if (authenticated && !this.active) {
|
if (authenticated && !this.active) {
|
||||||
this.active = true;
|
this.active = true;
|
||||||
this.start();
|
this.start();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { ActivityLog } from './activity-log.js';
|
|||||||
import type { LogBroadcaster } from './log-broadcaster.js';
|
import type { LogBroadcaster } from './log-broadcaster.js';
|
||||||
import type { RuntimeConfig } from './types.js';
|
import type { RuntimeConfig } from './types.js';
|
||||||
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
|
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
|
||||||
|
import type { AccountAuthContext } from '../services/account-auth-context.js';
|
||||||
|
|
||||||
/** Bag of everything the route modules might need. */
|
/** Bag of everything the route modules might need. */
|
||||||
export interface WebContext {
|
export interface WebContext {
|
||||||
@@ -43,6 +44,7 @@ export interface WebContext {
|
|||||||
strategySelector?: StrategySelector;
|
strategySelector?: StrategySelector;
|
||||||
browser?: BrowserClient;
|
browser?: BrowserClient;
|
||||||
sessionImporter?: RedditSessionImporter;
|
sessionImporter?: RedditSessionImporter;
|
||||||
|
accountAuth?: AccountAuthContext;
|
||||||
conversationState?: ConversationStateRepository;
|
conversationState?: ConversationStateRepository;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -52,9 +52,25 @@ export function createLoginRouter(ctx: WebContext): Router {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const loggedIn = await browser.isSessionValid();
|
const accountAuth = ctx.services.accountAuth;
|
||||||
const username = loggedIn ? await browser.getAuthenticatedUsername() : null;
|
const fullyAuthenticated = accountAuth
|
||||||
res.json({ ready: true, loggedIn, status: browser.getSessionState(), username });
|
? await accountAuth.ensureFullyAuthenticated()
|
||||||
|
: await browser.isSessionValid();
|
||||||
|
if (fullyAuthenticated) {
|
||||||
|
const username = accountAuth?.getSnapshot().redditUsername
|
||||||
|
?? await browser.getAuthenticatedUsername();
|
||||||
|
res.json({ ready: true, loggedIn: true, status: 'authenticated', username });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.isSessionValid();
|
||||||
|
const browserState = browser.getSessionState();
|
||||||
|
res.json({
|
||||||
|
ready: true,
|
||||||
|
loggedIn: false,
|
||||||
|
status: browserState === 'unreachable' ? 'unreachable' : 'login-required',
|
||||||
|
username: null,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.logger.error('Failed to check login status:', error);
|
ctx.logger.error('Failed to check login status:', error);
|
||||||
res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' });
|
res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' });
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { OpenRouterClient } from '../ai/openrouter.js';
|
|||||||
import type { StrategySelector } from '../services/strategy-selector.js';
|
import type { StrategySelector } from '../services/strategy-selector.js';
|
||||||
import type { BrowserClient } from '../browser/client.js';
|
import type { BrowserClient } from '../browser/client.js';
|
||||||
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
|
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
|
||||||
|
import type { AccountAuthContext } from '../services/account-auth-context.js';
|
||||||
import { ActivityLog } from './activity-log.js';
|
import { ActivityLog } from './activity-log.js';
|
||||||
import { LogBroadcaster } from './log-broadcaster.js';
|
import { LogBroadcaster } from './log-broadcaster.js';
|
||||||
import type { WebContext } from './context.js';
|
import type { WebContext } from './context.js';
|
||||||
@@ -205,6 +206,10 @@ export class WebServer {
|
|||||||
this.services.sessionImporter = sessionImporter;
|
this.services.sessionImporter = sessionImporter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAccountAuth(accountAuth: AccountAuthContext): void {
|
||||||
|
this.services.accountAuth = accountAuth;
|
||||||
|
}
|
||||||
|
|
||||||
setConversationStateRepository(conversationState: ConversationStateRepository): void {
|
setConversationStateRepository(conversationState: ConversationStateRepository): void {
|
||||||
this.services.conversationState = conversationState;
|
this.services.conversationState = conversationState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ describe('settings and access', () => {
|
|||||||
renderSettings('/settings?section=access');
|
renderSettings('/settings?section=access');
|
||||||
|
|
||||||
expect(await screen.findByText('Login required')).toBeInTheDocument();
|
expect(await screen.findByText('Login required')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/waiting for a session dump/i)).toBeInTheDocument();
|
expect(screen.getByText(/waiting for a fresh session dump/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/noVNC/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/noVNC/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('textbox', { name: /password/i })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable
|
|||||||
app.use(
|
app.use(
|
||||||
createLoginRouter({
|
createLoginRouter({
|
||||||
services: {
|
services: {
|
||||||
|
accountAuth: {
|
||||||
|
ensureFullyAuthenticated: async () => state === 'authenticated',
|
||||||
|
getSnapshot: () => ({ redditUsername: state === 'authenticated' ? 'example-account' : null }),
|
||||||
|
},
|
||||||
browser: {
|
browser: {
|
||||||
isSessionValid: async () => state === 'authenticated',
|
isSessionValid: async () => state === 'authenticated',
|
||||||
getAuthenticatedUsername: async () => state === 'authenticated' ? 'example-account' : null,
|
getAuthenticatedUsername: async () => state === 'authenticated' ? 'example-account' : null,
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ const credentials = {
|
|||||||
|
|
||||||
describe('RedditSessionImporter', () => {
|
describe('RedditSessionImporter', () => {
|
||||||
it('verifies both parts of the dump before activating the account', async () => {
|
it('verifies both parts of the dump before activating the account', async () => {
|
||||||
const browser = { importRedditCookies: vi.fn(), isSessionValid: vi.fn(async () => true) };
|
const browser = {
|
||||||
|
importRedditCookies: vi.fn(),
|
||||||
|
isSessionValid: vi.fn(async () => true),
|
||||||
|
getAuthenticatedUsername: vi.fn(async () => 'bot-account'),
|
||||||
|
};
|
||||||
const matrix = { whoami: vi.fn(async () => credentials.userId) };
|
const matrix = { whoami: vi.fn(async () => credentials.userId) };
|
||||||
const chat = { setMatrixClient: vi.fn() };
|
const chat = { setMatrixClient: vi.fn() };
|
||||||
const save = vi.fn();
|
const save = vi.fn();
|
||||||
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save);
|
const accountAuth = { markAuthenticated: vi.fn() };
|
||||||
|
const importer = new RedditSessionImporter(browser, chat as never, vi.fn(() => matrix as never), save, accountAuth as never);
|
||||||
|
|
||||||
await importer.import(cookies, credentials);
|
await importer.import(cookies, credentials);
|
||||||
|
|
||||||
@@ -27,15 +32,21 @@ describe('RedditSessionImporter', () => {
|
|||||||
expect(browser.importRedditCookies).toHaveBeenCalledWith(cookies);
|
expect(browser.importRedditCookies).toHaveBeenCalledWith(cookies);
|
||||||
expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix);
|
expect(chat.setMatrixClient).toHaveBeenCalledWith(matrix);
|
||||||
expect(save).toHaveBeenCalledWith(credentials);
|
expect(save).toHaveBeenCalledWith(credentials);
|
||||||
|
expect(accountAuth.markAuthenticated).toHaveBeenCalledWith({
|
||||||
|
accountId: credentials.userId,
|
||||||
|
redditUsername: 'bot-account',
|
||||||
|
matrixUserId: credentials.userId,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects non-Reddit Matrix homeservers without making a request', async () => {
|
it('rejects non-Reddit Matrix homeservers without making a request', async () => {
|
||||||
const createMatrix = vi.fn();
|
const createMatrix = vi.fn();
|
||||||
const importer = new RedditSessionImporter(
|
const importer = new RedditSessionImporter(
|
||||||
{ importRedditCookies: vi.fn(), isSessionValid: vi.fn() },
|
{ importRedditCookies: vi.fn(), isSessionValid: vi.fn(), getAuthenticatedUsername: vi.fn() },
|
||||||
{ setMatrixClient: vi.fn() } as never,
|
{ setMatrixClient: vi.fn() } as never,
|
||||||
createMatrix,
|
createMatrix,
|
||||||
vi.fn(),
|
vi.fn(),
|
||||||
|
{ markAuthenticated: vi.fn() } as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(importer.import(cookies, { ...credentials, homeserver: 'https://attacker.example' }))
|
await expect(importer.import(cookies, { ...credentials, homeserver: 'https://attacker.example' }))
|
||||||
|
|||||||
@@ -7,22 +7,19 @@ describe('SessionBoundRuntime', () => {
|
|||||||
const stop = vi.fn();
|
const stop = vi.fn();
|
||||||
const runtime = new SessionBoundRuntime(start, stop);
|
const runtime = new SessionBoundRuntime(start, stop);
|
||||||
|
|
||||||
expect(runtime.reconcile('login-required')).toBe(false);
|
expect(runtime.reconcile(false)).toBe(false);
|
||||||
expect(start).not.toHaveBeenCalled();
|
expect(start).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(runtime.reconcile('authenticated')).toBe(true);
|
expect(runtime.reconcile(true)).toBe(true);
|
||||||
expect(runtime.reconcile('authenticated')).toBe(true);
|
expect(runtime.reconcile(true)).toBe(true);
|
||||||
expect(start).toHaveBeenCalledOnce();
|
expect(start).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
expect(runtime.reconcile('login-required')).toBe(false);
|
expect(runtime.reconcile(false)).toBe(false);
|
||||||
expect(stop).toHaveBeenCalledOnce();
|
expect(stop).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['unknown', 'login-required', 'unreachable'] as const)(
|
it('treats incomplete authentication as waiting for an extension dump', () => {
|
||||||
'treats %s as waiting for an extension dump',
|
|
||||||
(state) => {
|
|
||||||
const runtime = new SessionBoundRuntime(vi.fn(), vi.fn());
|
const runtime = new SessionBoundRuntime(vi.fn(), vi.fn());
|
||||||
expect(runtime.reconcile(state)).toBe(false);
|
expect(runtime.reconcile(false)).toBe(false);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
@@ -10,6 +10,7 @@ describe('control route realtime events', () => {
|
|||||||
const bot = vi.fn();
|
const bot = vi.fn();
|
||||||
const context = {
|
const context = {
|
||||||
config: { browser: { dryRun: true } },
|
config: { browser: { dryRun: true } },
|
||||||
|
services: { accountAuth: { isFullyAuthenticated: () => true } },
|
||||||
setBotRunning: (next: boolean) => { running = next; },
|
setBotRunning: (next: boolean) => { running = next; },
|
||||||
activity: { push: vi.fn() },
|
activity: { push: vi.fn() },
|
||||||
logger: { info: vi.fn() },
|
logger: { info: vi.fn() },
|
||||||
|
|||||||
Reference in new issue
Block a user