Gate bot logic on full account authentication
This commit is contained in:
1 parent
4db6de8bf5
commit
2fd92bf82c
22 files changed
+629
-59
No files matched your search
+75
-38
@@ -23,8 +23,6 @@ import {
|
|||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixMediaDownloader,
|
MatrixMediaDownloader,
|
||||||
MatrixTokenRefresher,
|
MatrixTokenRefresher,
|
||||||
extractMatrixCredentials,
|
|
||||||
loadCachedCredentials,
|
|
||||||
type UsernameResolver,
|
type UsernameResolver,
|
||||||
type MatrixCredentials,
|
type MatrixCredentials,
|
||||||
} from './services/chat/index.js';
|
} from './services/chat/index.js';
|
||||||
@@ -33,6 +31,7 @@ import { MediaSyncService } from './services/media-sync.js';
|
|||||||
import { RateLimiter } from './utils/rate-limiter.js';
|
import { RateLimiter } from './utils/rate-limiter.js';
|
||||||
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
|
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
|
||||||
import { WebServer } from './web/server.js';
|
import { WebServer } from './web/server.js';
|
||||||
|
import { AccountAuthContext, type AuthenticatedAccount } from './services/account-auth-context.js';
|
||||||
|
|
||||||
export interface AppContainer {
|
export interface AppContainer {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -41,6 +40,7 @@ export interface AppContainer {
|
|||||||
|
|
||||||
browserClient: BrowserClient;
|
browserClient: BrowserClient;
|
||||||
messagePage: RedditMessagePage;
|
messagePage: RedditMessagePage;
|
||||||
|
accountAuth: AccountAuthContext;
|
||||||
|
|
||||||
postsRepo: PostsRepository;
|
postsRepo: PostsRepository;
|
||||||
messagesRepo: MessagesRepository;
|
messagesRepo: MessagesRepository;
|
||||||
@@ -97,6 +97,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
const page = await browserClient.getPage();
|
const page = await browserClient.getPage();
|
||||||
const messagePage = new RedditMessagePage(page, logger);
|
const messagePage = new RedditMessagePage(page, logger);
|
||||||
messagePage.setDryRun(config.browser.dryRun);
|
messagePage.setDryRun(config.browser.dryRun);
|
||||||
|
const accountAuth = new AccountAuthContext(logger);
|
||||||
|
|
||||||
// Login verification. When the web dashboard is enabled we treat a missing
|
// Login verification. When the web dashboard is enabled we treat a missing
|
||||||
// session as recoverable — the dashboard exposes a noVNC console so a human
|
// session as recoverable — the dashboard exposes a noVNC console so a human
|
||||||
@@ -150,36 +151,19 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
successfulMessagesRepo,
|
successfulMessagesRepo,
|
||||||
postsRepo,
|
postsRepo,
|
||||||
logger,
|
logger,
|
||||||
|
accountAuth,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Matrix chat backend (replaces the Playwright scraper) ----
|
// ---- Matrix chat backend (replaces the Playwright scraper) ----
|
||||||
// 1. Get credentials: prefer cached, fall back to a fresh sniff via the
|
// A cached bearer token is not accepted as application authentication.
|
||||||
// persistent browser session that we just verified is logged in.
|
// The current Chrome profile must yield a fresh token and Matrix identity
|
||||||
// If the sniff fails (e.g. egress blocked, chat.reddit.com refuses to
|
// before the shared account context unlocks any bot behavior.
|
||||||
// load), we *don't* crash bootstrap — the post-monitoring/processor
|
const matrixCreds: MatrixCredentials = {
|
||||||
// side of the bot doesn't need Matrix, and keeping the web UI up makes
|
|
||||||
// the situation diagnosable. Chat calls will fail with 401 until a
|
|
||||||
// subsequent token refresh succeeds.
|
|
||||||
let matrixCreds: MatrixCredentials | null = await loadCachedCredentials();
|
|
||||||
if (!matrixCreds) {
|
|
||||||
logger.info('No cached Matrix credentials — extracting via browser session...');
|
|
||||||
try {
|
|
||||||
matrixCreds = await extractMatrixCredentials(browserClient.getContext()!, logger);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
'[matrix] credential extraction failed — chat features will be disabled until the next successful refresh:',
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
matrixCreds = {
|
|
||||||
homeserver: 'https://matrix.redditspace.com',
|
homeserver: 'https://matrix.redditspace.com',
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
userId: '',
|
userId: '',
|
||||||
capturedAt: Date.now(),
|
capturedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.info(`Loaded cached Matrix credentials (user=${matrixCreds.userId})`);
|
|
||||||
}
|
|
||||||
const matrixClient = new MatrixClient(matrixCreds, logger);
|
const matrixClient = new MatrixClient(matrixCreds, logger);
|
||||||
|
|
||||||
// 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
|
||||||
@@ -190,13 +174,36 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
// 3. Token refresher — on 401, restore the persistent Reddit session when
|
// 3. Token refresher — on 401, restore the persistent Reddit session when
|
||||||
// possible, re-sniff once, and swap credentials into the chat service.
|
// possible, re-sniff once, and swap credentials into the chat service.
|
||||||
// Concurrent callers share that refresh rather than opening extra pages.
|
// Concurrent callers share that refresh rather than opening extra pages.
|
||||||
let chatService: ChatService;
|
const credentialState: { latest: MatrixCredentials | null } = { latest: null };
|
||||||
const tokenRefresher = new MatrixTokenRefresher(
|
const tokenRefresher = new MatrixTokenRefresher(
|
||||||
browserClient,
|
browserClient,
|
||||||
logger,
|
logger,
|
||||||
(fresh) => chatService.setMatrixClient(new MatrixClient(fresh, logger)),
|
(fresh) => {
|
||||||
|
credentialState.latest = fresh;
|
||||||
|
matrixClient.updateCredentials(fresh);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
accountAuth.setAuthenticator(async (): Promise<AuthenticatedAccount | null> => {
|
||||||
|
credentialState.latest = null;
|
||||||
|
await tokenRefresher.refresh();
|
||||||
|
const redditUsername = await browserClient.getAuthenticatedUsername();
|
||||||
|
const freshCredentials = credentialState.latest as MatrixCredentials | null;
|
||||||
|
if (!redditUsername || !freshCredentials?.userId) return null;
|
||||||
|
return {
|
||||||
|
accountId: freshCredentials.userId,
|
||||||
|
redditUsername,
|
||||||
|
matrixUserId: freshCredentials.userId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loggedIn) {
|
||||||
|
logger.info('Verifying full Reddit and Matrix account authentication...');
|
||||||
|
await accountAuth.ensureFullyAuthenticated();
|
||||||
|
} else {
|
||||||
|
accountAuth.invalidate('Interactive Reddit login is required');
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Username resolver — turns t2 IDs into real Reddit usernames so the
|
// 4. Username resolver — turns t2 IDs into real Reddit usernames so the
|
||||||
// dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint.
|
// dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint.
|
||||||
const usernameResolver: UsernameResolver = {
|
const usernameResolver: UsernameResolver = {
|
||||||
@@ -228,7 +235,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
chatService = new ChatService(
|
const chatService = new ChatService(
|
||||||
matrixClient,
|
matrixClient,
|
||||||
logger,
|
logger,
|
||||||
successfulMessagesRepo,
|
successfulMessagesRepo,
|
||||||
@@ -238,24 +245,52 @@ 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);
|
const rateLimiter = new RateLimiter(config, logger);
|
||||||
|
|
||||||
await chatService.loadCache();
|
await chatService.loadCache();
|
||||||
|
|
||||||
// Fire-and-forget startup sync: full if we've never backfilled, delta
|
let startupSyncAccountId: string | null = null;
|
||||||
// otherwise. Non-blocking so the rest of the bot can come up immediately;
|
let startupSyncInFlightAccountId: string | null = null;
|
||||||
// the dashboard's sync-status banner shows progress.
|
const startAuthenticatedAccount = async (account: AuthenticatedAccount): Promise<void> => {
|
||||||
chatService
|
if (
|
||||||
.runStartupSync()
|
startupSyncAccountId === account.accountId ||
|
||||||
.then(async ({ kind, chats }) => {
|
startupSyncInFlightAccountId === account.accountId
|
||||||
logger.info(`[chat] startup sync (${kind}) complete: ${chats} chats`);
|
) return;
|
||||||
// Resolve t2 IDs now that the full chat list is available.
|
startupSyncInFlightAccountId = account.accountId;
|
||||||
|
try {
|
||||||
|
const { kind, chats } = await chatService.runStartupSync();
|
||||||
await chatService.enrichUsernames();
|
await chatService.enrichUsernames();
|
||||||
})
|
if (accountAuth.isFullyAuthenticated()) {
|
||||||
.catch((err) => logger.error('[chat] startup sync failed:', err));
|
startupSyncAccountId = account.accountId;
|
||||||
|
logger.info(`[chat] startup sync (${kind}) complete for ${account.redditUsername}: ${chats} chats`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
startupSyncInFlightAccountId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
accountAuth.onAuthenticated((account) => startAuthenticatedAccount(account));
|
||||||
|
const authenticatedAccount = accountAuth.getSnapshot();
|
||||||
|
if (
|
||||||
|
authenticatedAccount.status === 'authenticated' &&
|
||||||
|
authenticatedAccount.accountId &&
|
||||||
|
authenticatedAccount.redditUsername &&
|
||||||
|
authenticatedAccount.matrixUserId
|
||||||
|
) {
|
||||||
|
void startAuthenticatedAccount({
|
||||||
|
accountId: authenticatedAccount.accountId,
|
||||||
|
redditUsername: authenticatedAccount.redditUsername,
|
||||||
|
matrixUserId: authenticatedAccount.matrixUserId,
|
||||||
|
}).catch((err) => logger.error('[chat] startup sync failed:', err));
|
||||||
|
}
|
||||||
|
|
||||||
let webServer: WebServer | null = null;
|
let webServer: WebServer | null = null;
|
||||||
if (config.web.enabled) {
|
if (config.web.enabled) {
|
||||||
@@ -291,6 +326,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
webServer.setAiClient(aiClient);
|
webServer.setAiClient(aiClient);
|
||||||
webServer.setStrategySelector(strategySelector);
|
webServer.setStrategySelector(strategySelector);
|
||||||
webServer.setBrowserClient(browserClient);
|
webServer.setBrowserClient(browserClient);
|
||||||
|
webServer.setAccountAuthContext(accountAuth);
|
||||||
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
|
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +348,7 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
db,
|
db,
|
||||||
browserClient,
|
browserClient,
|
||||||
messagePage,
|
messagePage,
|
||||||
|
accountAuth,
|
||||||
postsRepo,
|
postsRepo,
|
||||||
messagesRepo,
|
messagesRepo,
|
||||||
successfulMessagesRepo,
|
successfulMessagesRepo,
|
||||||
|
|||||||
+18
-5
@@ -112,6 +112,15 @@ export class BrowserClient {
|
|||||||
return this.sessionState;
|
return this.sessionState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAuthenticatedUsername(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const page = await this.getPage();
|
||||||
|
return await this.readAuthenticatedUsername(page);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
if (this.connectedBrowser) {
|
if (this.connectedBrowser) {
|
||||||
this.logger.info('Closing Google Chrome connection');
|
this.logger.info('Closing Google Chrome connection');
|
||||||
@@ -185,19 +194,23 @@ export class BrowserClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async isLoggedInViaApi(page: Page): Promise<boolean> {
|
private async isLoggedInViaApi(page: Page): Promise<boolean> {
|
||||||
|
return (await this.readAuthenticatedUsername(page)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readAuthenticatedUsername(page: Page): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
return await page.evaluate(async () => {
|
return await page.evaluate(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
|
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return null;
|
||||||
const data = await res.json();
|
const data = await res.json() as { name?: unknown };
|
||||||
return Boolean(data && data.name);
|
return typeof data.name === 'string' && data.name ? data.name : null;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -19,6 +19,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
config,
|
config,
|
||||||
logger,
|
logger,
|
||||||
browserClient,
|
browserClient,
|
||||||
|
accountAuth,
|
||||||
postsRepo,
|
postsRepo,
|
||||||
messagesRepo,
|
messagesRepo,
|
||||||
redditApi,
|
redditApi,
|
||||||
@@ -65,7 +66,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60_000);
|
const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60_000);
|
||||||
|
|
||||||
const checkResponses = async (): Promise<void> => {
|
const checkResponses = async (): Promise<void> => {
|
||||||
if (isShuttingDown) return;
|
if (isShuttingDown || !accountAuth.isFullyAuthenticated()) return;
|
||||||
try {
|
try {
|
||||||
logger.debug('Checking for responses to sent messages (Background Thread)...');
|
logger.debug('Checking for responses to sent messages (Background Thread)...');
|
||||||
const responses = await responseChecker.checkForResponses();
|
const responses = await responseChecker.checkForResponses();
|
||||||
@@ -92,11 +93,25 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
responseCheckInterval = setInterval(checkResponses, RESPONSE_CHECK_INTERVAL_MS);
|
responseCheckInterval = setInterval(checkResponses, RESPONSE_CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
let lastPausedLog = 0;
|
let lastPausedLog = 0;
|
||||||
|
let lastAuthPausedLog = 0;
|
||||||
let lastCooldownLog = 0;
|
let lastCooldownLog = 0;
|
||||||
const COOLDOWN_LOG_INTERVAL_MS = 5 * 60_000;
|
const COOLDOWN_LOG_INTERVAL_MS = 5 * 60_000;
|
||||||
|
|
||||||
while (!isShuttingDown) {
|
while (!isShuttingDown) {
|
||||||
try {
|
try {
|
||||||
|
if (!accountAuth.isFullyAuthenticated()) {
|
||||||
|
await accountAuth.ensureFullyAuthenticated();
|
||||||
|
}
|
||||||
|
if (!accountAuth.isFullyAuthenticated()) {
|
||||||
|
if (Date.now() - lastAuthPausedLog > PAUSED_LOG_INTERVAL_MS) {
|
||||||
|
logger.info('Bot logic is locked until full Reddit and Matrix authentication is verified.');
|
||||||
|
lastAuthPausedLog = Date.now();
|
||||||
|
}
|
||||||
|
await sleep(PAUSE_POLL_MS);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastAuthPausedLog = 0;
|
||||||
|
|
||||||
if (webServer && !webServer.isRunning()) {
|
if (webServer && !webServer.isRunning()) {
|
||||||
if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) {
|
if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) {
|
||||||
logger.info('Bot is paused. Use the web dashboard to start.');
|
logger.info('Bot is paused. Use the web dashboard to start.');
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import type { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export type AccountAuthStatus =
|
||||||
|
| 'login-required'
|
||||||
|
| 'checking'
|
||||||
|
| 'authenticated';
|
||||||
|
|
||||||
|
export interface AuthenticatedAccount {
|
||||||
|
accountId: string;
|
||||||
|
redditUsername: string;
|
||||||
|
matrixUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountAuthSnapshot {
|
||||||
|
status: AccountAuthStatus;
|
||||||
|
accountId: string | null;
|
||||||
|
redditUsername: string | null;
|
||||||
|
matrixUserId: string | null;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountAuthGate {
|
||||||
|
isFullyAuthenticated(): boolean;
|
||||||
|
invalidate(reason: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccountAuthenticator = () => Promise<AuthenticatedAccount | null>;
|
||||||
|
export type AccountAuthenticatedListener = (account: AuthenticatedAccount) => void | Promise<void>;
|
||||||
|
|
||||||
|
export interface AccountAuthContextOptions {
|
||||||
|
leaseMs?: number;
|
||||||
|
retryDelayMs?: number;
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const denyAccountAuth: AccountAuthGate = {
|
||||||
|
isFullyAuthenticated: () => false,
|
||||||
|
invalidate: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AccountAuthContext implements AccountAuthGate {
|
||||||
|
private snapshot: AccountAuthSnapshot = {
|
||||||
|
status: 'login-required',
|
||||||
|
accountId: null,
|
||||||
|
redditUsername: null,
|
||||||
|
matrixUserId: null,
|
||||||
|
reason: 'Full Reddit and Matrix authentication has not been verified',
|
||||||
|
};
|
||||||
|
private authenticator: AccountAuthenticator | null = null;
|
||||||
|
private authenticationInFlight: Promise<boolean> | null = null;
|
||||||
|
private readonly listeners = new Set<AccountAuthenticatedListener>();
|
||||||
|
private authenticatedUntil = 0;
|
||||||
|
private nextAuthenticationAttemptAt = 0;
|
||||||
|
private readonly leaseMs: number;
|
||||||
|
private readonly retryDelayMs: number;
|
||||||
|
private readonly now: () => number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly logger: Logger,
|
||||||
|
options: AccountAuthContextOptions = {},
|
||||||
|
) {
|
||||||
|
this.leaseMs = options.leaseMs ?? 30 * 60 * 1000;
|
||||||
|
this.retryDelayMs = options.retryDelayMs ?? 60 * 1000;
|
||||||
|
this.now = options.now ?? Date.now;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthenticator(authenticator: AccountAuthenticator): void {
|
||||||
|
this.authenticator = authenticator;
|
||||||
|
}
|
||||||
|
|
||||||
|
isFullyAuthenticated(): boolean {
|
||||||
|
if (this.snapshot.status !== 'authenticated') return false;
|
||||||
|
if (this.now() < this.authenticatedUntil) return true;
|
||||||
|
this.invalidate('Full account authentication lease expired');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(): AccountAuthSnapshot {
|
||||||
|
this.isFullyAuthenticated();
|
||||||
|
return { ...this.snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
markAuthenticated(account: AuthenticatedAccount): void {
|
||||||
|
if (!account.accountId || !account.redditUsername || !account.matrixUserId) {
|
||||||
|
throw new Error('A fully authenticated account requires Reddit and Matrix identities');
|
||||||
|
}
|
||||||
|
if (account.accountId !== account.matrixUserId) {
|
||||||
|
throw new Error('Authenticated account ID must match the Matrix user ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedAccount = this.snapshot.accountId !== account.accountId;
|
||||||
|
const wasAuthenticated = this.isFullyAuthenticated();
|
||||||
|
this.snapshot = {
|
||||||
|
status: 'authenticated',
|
||||||
|
accountId: account.accountId,
|
||||||
|
redditUsername: account.redditUsername,
|
||||||
|
matrixUserId: account.matrixUserId,
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
this.authenticatedUntil = this.now() + this.leaseMs;
|
||||||
|
this.nextAuthenticationAttemptAt = 0;
|
||||||
|
if (!wasAuthenticated || changedAccount) {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
void Promise.resolve(listener(account)).catch((error) => {
|
||||||
|
this.logger.error('Authenticated account listener failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(reason: string): void {
|
||||||
|
this.authenticatedUntil = 0;
|
||||||
|
this.snapshot = {
|
||||||
|
status: 'login-required',
|
||||||
|
accountId: null,
|
||||||
|
redditUsername: null,
|
||||||
|
matrixUserId: null,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onAuthenticated(listener: AccountAuthenticatedListener): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureFullyAuthenticated(): Promise<boolean> {
|
||||||
|
if (this.isFullyAuthenticated()) return Promise.resolve(true);
|
||||||
|
if (this.authenticationInFlight) return this.authenticationInFlight;
|
||||||
|
if (!this.authenticator) return Promise.resolve(false);
|
||||||
|
if (this.now() < this.nextAuthenticationAttemptAt) return Promise.resolve(false);
|
||||||
|
|
||||||
|
this.snapshot = { ...this.snapshot, status: 'checking', reason: null };
|
||||||
|
this.authenticationInFlight = this.authenticate().finally(() => {
|
||||||
|
this.authenticationInFlight = null;
|
||||||
|
});
|
||||||
|
return this.authenticationInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async authenticate(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const account = await this.authenticator!();
|
||||||
|
if (!account) {
|
||||||
|
this.invalidate('Full Reddit and Matrix authentication is required');
|
||||||
|
this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.markAuthenticated(account);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.invalidate(error instanceof Error ? error.message : String(error));
|
||||||
|
this.nextAuthenticationAttemptAt = this.now() + this.retryDelayMs;
|
||||||
|
this.logger.warn('Full account authentication failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type { ActiveChat, ChatMessage, ScrapedChatMessage, InvitationCooldown }
|
|||||||
|
|
||||||
export type { InvitationCooldown } from './types.js';
|
export type { InvitationCooldown } from './types.js';
|
||||||
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
|
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
|
||||||
|
import type { AccountAuthGate } from '../account-auth-context.js';
|
||||||
|
|
||||||
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
|
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
|
||||||
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
|
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
|
||||||
@@ -129,6 +130,7 @@ export class ChatService {
|
|||||||
cache?: ChatCache,
|
cache?: ChatCache,
|
||||||
private messagesRepo?: MessagesRepository,
|
private messagesRepo?: MessagesRepository,
|
||||||
private redditUsersRepo?: RedditUsersRepository,
|
private redditUsersRepo?: RedditUsersRepository,
|
||||||
|
private accountAuth?: AccountAuthGate,
|
||||||
) {
|
) {
|
||||||
this.cache = cache ?? new ChatCache(logger);
|
this.cache = cache ?? new ChatCache(logger);
|
||||||
if (this.redditUsersRepo) {
|
if (this.redditUsersRepo) {
|
||||||
@@ -176,6 +178,11 @@ export class ChatService {
|
|||||||
* refresh itself fails, we propagate.
|
* 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()) {
|
||||||
|
throw new MatrixAuthenticationUnavailableError(
|
||||||
|
new Error('Full account authentication is required'),
|
||||||
|
);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -184,9 +191,15 @@ export class ChatService {
|
|||||||
try {
|
try {
|
||||||
await this.tokenRefresher.refresh();
|
await this.tokenRefresher.refresh();
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
|
this.accountAuth?.invalidate('Matrix authentication could not be restored');
|
||||||
throw new MatrixAuthenticationUnavailableError(refreshError);
|
throw new MatrixAuthenticationUnavailableError(refreshError);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
|
} catch (retryError) {
|
||||||
|
this.accountAuth?.invalidate('Matrix authentication was rejected after refresh');
|
||||||
|
throw retryError;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -459,6 +472,7 @@ export class ChatService {
|
|||||||
* `chat.messages`.
|
* `chat.messages`.
|
||||||
*/
|
*/
|
||||||
async loadConversationDetails(chat: ActiveChat): Promise<void> {
|
async loadConversationDetails(chat: ActiveChat): Promise<void> {
|
||||||
|
if (!this.accountAuth?.isFullyAuthenticated()) return;
|
||||||
return this.loadDetails(chat);
|
return this.loadDetails(chat);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +482,7 @@ export class ChatService {
|
|||||||
* a raw Matrix room ID like `!abc:reddit.com`.
|
* a raw Matrix room ID like `!abc:reddit.com`.
|
||||||
*/
|
*/
|
||||||
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
|
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
|
||||||
|
if (!this.accountAuth?.isFullyAuthenticated()) return false;
|
||||||
const active = this.getInvitationCooldown();
|
const active = this.getInvitationCooldown();
|
||||||
if (active) {
|
if (active) {
|
||||||
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
|
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
|
||||||
@@ -508,6 +523,7 @@ export class ChatService {
|
|||||||
* line. Returns the new chat (or null on failure).
|
* line. Returns the new chat (or null on failure).
|
||||||
*/
|
*/
|
||||||
async startConversation(username: string, message: string): Promise<ActiveChat | null> {
|
async startConversation(username: string, message: string): Promise<ActiveChat | null> {
|
||||||
|
if (!this.accountAuth?.isFullyAuthenticated()) return null;
|
||||||
const active = this.getInvitationCooldown();
|
const active = this.getInvitationCooldown();
|
||||||
if (active) {
|
if (active) {
|
||||||
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
|
const secondsLeft = Math.ceil((active.until - Date.now()) / 1000);
|
||||||
@@ -571,7 +587,7 @@ export class ChatService {
|
|||||||
* old Playwright sender; always returns true.
|
* old Playwright sender; always returns true.
|
||||||
*/
|
*/
|
||||||
async verifyMessageSent(_recipient: string, _messageText: string): Promise<boolean> {
|
async verifyMessageSent(_recipient: string, _messageText: string): Promise<boolean> {
|
||||||
return true;
|
return this.accountAuth?.isFullyAuthenticated() ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1026,6 +1042,10 @@ export class ChatService {
|
|||||||
* cache. Best-effort; safe to await or fire-and-forget.
|
* cache. Best-effort; safe to await or fire-and-forget.
|
||||||
*/
|
*/
|
||||||
async enrichUsernames(): Promise<void> {
|
async enrichUsernames(): Promise<void> {
|
||||||
|
if (!this.accountAuth?.isFullyAuthenticated()) {
|
||||||
|
this.logger.debug('[chat] username enrichment skipped until full account authentication');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this.usernameResolver) return;
|
if (!this.usernameResolver) return;
|
||||||
let dirty = false;
|
let dirty = false;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Logger } from '../utils/logger.js';
|
import type { Logger } from '../utils/logger.js';
|
||||||
import type { ChatService } from './chat.js';
|
import type { ChatService } from './chat.js';
|
||||||
|
import { denyAccountAuth, type AccountAuthGate } from './account-auth-context.js';
|
||||||
|
|
||||||
export interface MediaSyncStatus {
|
export interface MediaSyncStatus {
|
||||||
isSyncing: boolean;
|
isSyncing: boolean;
|
||||||
@@ -22,7 +23,8 @@ export class MediaSyncService {
|
|||||||
constructor(
|
constructor(
|
||||||
private chatService: ChatService,
|
private chatService: ChatService,
|
||||||
private logger: Logger,
|
private logger: Logger,
|
||||||
intervalMs: number = 30 * 60 * 1000 // default 30 minutes
|
intervalMs: number = 30 * 60 * 1000, // default 30 minutes
|
||||||
|
private accountAuth: AccountAuthGate = denyAccountAuth,
|
||||||
) {
|
) {
|
||||||
this.intervalMs = intervalMs;
|
this.intervalMs = intervalMs;
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,10 @@ export class MediaSyncService {
|
|||||||
* Returns immediately if a sync is already in progress.
|
* Returns immediately if a sync is already in progress.
|
||||||
*/
|
*/
|
||||||
async triggerSync(full = false): Promise<boolean> {
|
async triggerSync(full = false): Promise<boolean> {
|
||||||
|
if (!this.accountAuth.isFullyAuthenticated()) {
|
||||||
|
this.logger.warn('Media sync: blocked until the account is fully authenticated');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (this.syncing) {
|
if (this.syncing) {
|
||||||
this.logger.info('Media sync: sync already in progress, skipping trigger');
|
this.logger.info('Media sync: sync already in progress, skipping trigger');
|
||||||
return false;
|
return false;
|
||||||
@@ -86,6 +92,10 @@ export class MediaSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async runSync(full = false): Promise<void> {
|
private async runSync(full = false): Promise<void> {
|
||||||
|
if (!this.accountAuth.isFullyAuthenticated()) {
|
||||||
|
this.logger.debug('Media sync: skipped because the account is not fully authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.syncing) return;
|
if (this.syncing) return;
|
||||||
this.syncing = true;
|
this.syncing = true;
|
||||||
this.lastError = null;
|
this.lastError = null;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Page } from 'playwright';
|
|||||||
import type { Logger } from '../utils/logger.js';
|
import type { Logger } from '../utils/logger.js';
|
||||||
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord } from '../database/repositories.js';
|
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord } from '../database/repositories.js';
|
||||||
import type { PostsRepository } from '../database/repositories.js';
|
import type { PostsRepository } from '../database/repositories.js';
|
||||||
|
import type { AccountAuthGate } from './account-auth-context.js';
|
||||||
|
|
||||||
export interface ResponseCheckResult {
|
export interface ResponseCheckResult {
|
||||||
messageId: number;
|
messageId: number;
|
||||||
@@ -16,11 +17,16 @@ export class ResponseCheckerService {
|
|||||||
private messagesRepo: MessagesRepository,
|
private messagesRepo: MessagesRepository,
|
||||||
private successfulMessagesRepo: SuccessfulMessagesRepository,
|
private successfulMessagesRepo: SuccessfulMessagesRepository,
|
||||||
private postsRepo: PostsRepository,
|
private postsRepo: PostsRepository,
|
||||||
private logger: Logger
|
private logger: Logger,
|
||||||
|
private accountAuth?: AccountAuthGate,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async checkForResponses(): Promise<ResponseCheckResult[]> {
|
async checkForResponses(): Promise<ResponseCheckResult[]> {
|
||||||
const results: ResponseCheckResult[] = [];
|
const results: ResponseCheckResult[] = [];
|
||||||
|
if (!this.accountAuth?.isFullyAuthenticated()) {
|
||||||
|
this.logger.debug('Response check skipped until full account authentication');
|
||||||
|
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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { RateLimiter } from '../utils/rate-limiter.js';
|
|||||||
import type { OpenRouterClient } from '../ai/openrouter.js';
|
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 { AccountAuthContext } from '../services/account-auth-context.js';
|
||||||
import type { ActivityLog } from './activity-log.js';
|
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';
|
||||||
@@ -40,6 +41,7 @@ export interface WebContext {
|
|||||||
ai?: OpenRouterClient;
|
ai?: OpenRouterClient;
|
||||||
strategySelector?: StrategySelector;
|
strategySelector?: StrategySelector;
|
||||||
browser?: BrowserClient;
|
browser?: BrowserClient;
|
||||||
|
accountAuth?: AccountAuthContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Bot pause/resume toggle read by the main loop. */
|
/** Bot pause/resume toggle read by the main loop. */
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ export function createControlRouter(ctx: WebContext): Router {
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/start', (_req, res) => {
|
router.post('/start', (_req, res) => {
|
||||||
|
if (!ctx.services.accountAuth?.isFullyAuthenticated()) {
|
||||||
|
ctx.logger.warn('Bot start blocked until full account authentication');
|
||||||
|
res.status(409).json({ success: false, running: false, authenticated: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
ctx.setBotRunning(true);
|
ctx.setBotRunning(true);
|
||||||
ctx.activity.push('sent', 'Bot started by user');
|
ctx.activity.push('sent', 'Bot started by user');
|
||||||
ctx.logger.info('Bot started via web UI');
|
ctx.logger.info('Bot started via web UI');
|
||||||
|
|||||||
+14
-3
@@ -18,13 +18,23 @@ export function createLoginRouter(ctx: WebContext): Router {
|
|||||||
|
|
||||||
router.get('/login/status', async (_req, res) => {
|
router.get('/login/status', async (_req, res) => {
|
||||||
const browser = ctx.services.browser;
|
const browser = ctx.services.browser;
|
||||||
if (!browser) {
|
const accountAuth = ctx.services.accountAuth;
|
||||||
|
if (!browser || !accountAuth) {
|
||||||
res.status(503).json({ ready: false, error: 'browser not initialised' });
|
res.status(503).json({ ready: false, error: 'browser not initialised' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const loggedIn = await browser.isSessionValid();
|
const loggedIn = await accountAuth.ensureFullyAuthenticated();
|
||||||
res.json({ ready: true, loggedIn, status: browser.getSessionState() });
|
const browserState = browser.getSessionState();
|
||||||
|
res.json({
|
||||||
|
ready: true,
|
||||||
|
loggedIn,
|
||||||
|
status: loggedIn
|
||||||
|
? 'authenticated'
|
||||||
|
: browserState === 'unreachable'
|
||||||
|
? 'unreachable'
|
||||||
|
: 'login-required',
|
||||||
|
});
|
||||||
} 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' });
|
||||||
@@ -40,6 +50,7 @@ export function createLoginRouter(ctx: WebContext): Router {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
ctx.services.accountAuth?.invalidate('Interactive Reddit login requested');
|
||||||
const page = await browser.getPage();
|
const page = await browser.getPage();
|
||||||
await page.bringToFront().catch(() => undefined);
|
await page.bringToFront().catch(() => undefined);
|
||||||
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' });
|
await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' });
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { RateLimiter } from '../utils/rate-limiter.js';
|
|||||||
import type { OpenRouterClient } from '../ai/openrouter.js';
|
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 { 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';
|
||||||
@@ -175,6 +176,10 @@ export class WebServer {
|
|||||||
this.services.browser = browserClient;
|
this.services.browser = browserClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAccountAuthContext(accountAuth: AccountAuthContext): void {
|
||||||
|
this.services.accountAuth = accountAuth;
|
||||||
|
}
|
||||||
|
|
||||||
logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
|
logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
|
||||||
this.activity.push(type, message, recipient, imageUrl);
|
this.activity.push(type, message, recipient, imageUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AccountAuthContext } from '../src/services/account-auth-context.js';
|
||||||
|
import { createMockLogger } from './mockLogger.js';
|
||||||
|
|
||||||
|
describe('AccountAuthContext', () => {
|
||||||
|
it('fails closed until Reddit and Matrix resolve to one authenticated account', async () => {
|
||||||
|
const auth = new AccountAuthContext(createMockLogger());
|
||||||
|
const authenticate = vi.fn().mockResolvedValue({
|
||||||
|
accountId: '@t2_account:reddit.com',
|
||||||
|
redditUsername: 'account',
|
||||||
|
matrixUserId: '@t2_account:reddit.com',
|
||||||
|
});
|
||||||
|
auth.setAuthenticator(authenticate);
|
||||||
|
|
||||||
|
expect(auth.isFullyAuthenticated()).toBe(false);
|
||||||
|
expect(auth.getSnapshot().status).toBe('login-required');
|
||||||
|
|
||||||
|
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(auth.isFullyAuthenticated()).toBe(true);
|
||||||
|
expect(auth.getSnapshot()).toMatchObject({
|
||||||
|
status: 'authenticated',
|
||||||
|
accountId: '@t2_account:reddit.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces concurrent authentication checks globally', async () => {
|
||||||
|
const auth = new AccountAuthContext(createMockLogger());
|
||||||
|
let resolveAuth: ((account: {
|
||||||
|
accountId: string;
|
||||||
|
redditUsername: string;
|
||||||
|
matrixUserId: string;
|
||||||
|
}) => void) | undefined;
|
||||||
|
const authenticate = vi.fn(() => new Promise((resolve) => { resolveAuth = resolve; }));
|
||||||
|
auth.setAuthenticator(authenticate);
|
||||||
|
|
||||||
|
const first = auth.ensureFullyAuthenticated();
|
||||||
|
const second = auth.ensureFullyAuthenticated();
|
||||||
|
await vi.waitFor(() => expect(authenticate).toHaveBeenCalledOnce());
|
||||||
|
resolveAuth?.({
|
||||||
|
accountId: '@t2_account:reddit.com',
|
||||||
|
redditUsername: 'account',
|
||||||
|
matrixUserId: '@t2_account:reddit.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates the whole account context when either login layer expires', async () => {
|
||||||
|
const auth = new AccountAuthContext(createMockLogger());
|
||||||
|
auth.markAuthenticated({
|
||||||
|
accountId: '@t2_account:reddit.com',
|
||||||
|
redditUsername: 'account',
|
||||||
|
matrixUserId: '@t2_account:reddit.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
auth.invalidate('matrix token rejected');
|
||||||
|
|
||||||
|
expect(auth.isFullyAuthenticated()).toBe(false);
|
||||||
|
expect(auth.getSnapshot()).toMatchObject({
|
||||||
|
status: 'login-required',
|
||||||
|
accountId: null,
|
||||||
|
reason: 'matrix token rejected',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when the authentication lease expires', () => {
|
||||||
|
let now = 1_000;
|
||||||
|
const auth = new AccountAuthContext(createMockLogger(), {
|
||||||
|
leaseMs: 100,
|
||||||
|
retryDelayMs: 1_000,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
auth.markAuthenticated({
|
||||||
|
accountId: '@t2_account:reddit.com',
|
||||||
|
redditUsername: 'account',
|
||||||
|
matrixUserId: '@t2_account:reddit.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
now = 1_101;
|
||||||
|
|
||||||
|
expect(auth.isFullyAuthenticated()).toBe(false);
|
||||||
|
expect(auth.getSnapshot().status).toBe('login-required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throttles failed global authentication checks', async () => {
|
||||||
|
let now = 1_000;
|
||||||
|
const auth = new AccountAuthContext(createMockLogger(), {
|
||||||
|
leaseMs: 100,
|
||||||
|
retryDelayMs: 1_000,
|
||||||
|
now: () => now,
|
||||||
|
});
|
||||||
|
const authenticate = vi.fn().mockResolvedValue(null);
|
||||||
|
auth.setAuthenticator(authenticate);
|
||||||
|
|
||||||
|
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false);
|
||||||
|
await expect(auth.ensureFullyAuthenticated()).resolves.toBe(false);
|
||||||
|
expect(authenticate).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
now = 2_001;
|
||||||
|
await auth.ensureFullyAuthenticated();
|
||||||
|
expect(authenticate).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { ChatService } from '../src/services/chat/service.js';
|
||||||
|
import { ChatCache } from '../src/services/chat/cache.js';
|
||||||
|
import { MatrixClient } from '../src/services/chat/matrix/client.js';
|
||||||
|
import { createMockLogger } from './mockLogger.js';
|
||||||
|
|
||||||
|
describe('ChatService account authentication gate', () => {
|
||||||
|
it('does not make Matrix requests before full account authentication', async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const matrix = new MatrixClient({
|
||||||
|
homeserver: 'https://matrix.example.test',
|
||||||
|
accessToken: 'cached-but-unverified',
|
||||||
|
userId: '@t2_account:reddit.com',
|
||||||
|
capturedAt: 1,
|
||||||
|
}, logger, { minRequestIntervalMs: 0 });
|
||||||
|
matrix.sync = vi.fn();
|
||||||
|
const cache = new ChatCache(
|
||||||
|
logger,
|
||||||
|
path.join(os.tmpdir(), `chat-auth-${Date.now()}-${Math.random()}.json`),
|
||||||
|
);
|
||||||
|
const auth = { isFullyAuthenticated: vi.fn(() => false) };
|
||||||
|
const service = new ChatService(
|
||||||
|
matrix,
|
||||||
|
logger,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
cache,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.refreshTopChats();
|
||||||
|
|
||||||
|
expect(matrix.sync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { ChatService } from '../src/services/chat/service.js';
|
|||||||
import { ChatCache } from '../src/services/chat/cache.js';
|
import { ChatCache } from '../src/services/chat/cache.js';
|
||||||
import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js';
|
import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js';
|
||||||
import { createMockLogger } from './mockLogger.js';
|
import { createMockLogger } from './mockLogger.js';
|
||||||
|
import { authenticatedAccountAuth } from './testUtils.js';
|
||||||
|
|
||||||
const creds = {
|
const creds = {
|
||||||
homeserver: 'https://matrix.example.test',
|
homeserver: 'https://matrix.example.test',
|
||||||
@@ -29,7 +30,7 @@ function buildService() {
|
|||||||
logger,
|
logger,
|
||||||
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
|
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
|
||||||
);
|
);
|
||||||
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache);
|
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
||||||
return { svc, logger };
|
return { svc, logger };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ describe('ChatService invitation cooldown', () => {
|
|||||||
try {
|
try {
|
||||||
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
||||||
const cache1 = new ChatCache(logger, cacheFile);
|
const cache1 = new ChatCache(logger, cacheFile);
|
||||||
const svc1 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache1);
|
const svc1 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache1, undefined, undefined, authenticatedAccountAuth);
|
||||||
svc1.setInvitationCooldown(
|
svc1.setInvitationCooldown(
|
||||||
new MatrixRateLimitError('429', 120_000, 'M_LIMIT_EXCEEDED', null),
|
new MatrixRateLimitError('429', 120_000, 'M_LIMIT_EXCEEDED', null),
|
||||||
);
|
);
|
||||||
@@ -105,7 +106,7 @@ describe('ChatService invitation cooldown', () => {
|
|||||||
await cache1.save();
|
await cache1.save();
|
||||||
|
|
||||||
const cache2 = new ChatCache(logger, cacheFile);
|
const cache2 = new ChatCache(logger, cacheFile);
|
||||||
const svc2 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache2);
|
const svc2 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache2, undefined, undefined, authenticatedAccountAuth);
|
||||||
await svc2.loadCache();
|
await svc2.loadCache();
|
||||||
const restored = svc2.getInvitationCooldown();
|
const restored = svc2.getInvitationCooldown();
|
||||||
expect(restored).not.toBeNull();
|
expect(restored).not.toBeNull();
|
||||||
@@ -144,7 +145,7 @@ describe('ChatService invitation cooldown', () => {
|
|||||||
|
|
||||||
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
||||||
const cache = new ChatCache(logger, cacheFile);
|
const cache = new ChatCache(logger, cacheFile);
|
||||||
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache);
|
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
||||||
|
|
||||||
// Cooldown fires before loadCache() — the in-memory chats are still [].
|
// Cooldown fires before loadCache() — the in-memory chats are still [].
|
||||||
svc.setInvitationCooldown(
|
svc.setInvitationCooldown(
|
||||||
@@ -178,7 +179,7 @@ describe('ChatService invitation cooldown', () => {
|
|||||||
logger,
|
logger,
|
||||||
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
|
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
|
||||||
);
|
);
|
||||||
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache);
|
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
||||||
|
|
||||||
const result = await svc.sendChatMessage('!room:example.test', 'hi');
|
const result = await svc.sendChatMessage('!room:example.test', 'hi');
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MatrixClient } from '../src/services/chat/matrix/client.js';
|
|||||||
import { MediaService } from '../src/services/media.js';
|
import { MediaService } from '../src/services/media.js';
|
||||||
import type { MediaDownloader } from '../src/services/media.js';
|
import type { MediaDownloader } from '../src/services/media.js';
|
||||||
import { createMockLogger } from './mockLogger.js';
|
import { createMockLogger } from './mockLogger.js';
|
||||||
|
import { authenticatedAccountAuth } from './testUtils.js';
|
||||||
|
|
||||||
const creds = {
|
const creds = {
|
||||||
homeserver: 'https://matrix.example.test',
|
homeserver: 'https://matrix.example.test',
|
||||||
@@ -60,7 +61,7 @@ describe('ChatService delta sync — media attachment', () => {
|
|||||||
const downloader = buildDownloader();
|
const downloader = buildDownloader();
|
||||||
const media = new MediaService(downloader, logger, tmpDir);
|
const media = new MediaService(downloader, logger, tmpDir);
|
||||||
const cache = new ChatCache(logger, path.join(tmpDir, 'chat-cache.json'));
|
const cache = new ChatCache(logger, path.join(tmpDir, 'chat-cache.json'));
|
||||||
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache);
|
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
||||||
|
|
||||||
await svc.getActiveChats();
|
await svc.getActiveChats();
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ describe('ChatService delta sync — media attachment', () => {
|
|||||||
|
|
||||||
const media = new MediaService(buildDownloader(), logger, tmpDir);
|
const media = new MediaService(buildDownloader(), logger, tmpDir);
|
||||||
const cache = new ChatCache(logger, cacheFile);
|
const cache = new ChatCache(logger, cacheFile);
|
||||||
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache);
|
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
||||||
await svc.loadCache();
|
await svc.loadCache();
|
||||||
|
|
||||||
// refreshTopChats always runs a /sync (unlike getActiveChats, which can
|
// refreshTopChats always runs a /sync (unlike getActiveChats, which can
|
||||||
@@ -176,7 +177,7 @@ describe('ChatService delta sync — media attachment', () => {
|
|||||||
logger,
|
logger,
|
||||||
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
|
path.join(os.tmpdir(), `chat-cache-${Date.now()}-${Math.random()}.json`),
|
||||||
);
|
);
|
||||||
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache);
|
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
||||||
await expect(svc.getActiveChats()).resolves.toBeDefined();
|
await expect(svc.getActiveChats()).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@ import { ChatService } from '../src/services/chat/service.js';
|
|||||||
import { ChatCache } from '../src/services/chat/cache.js';
|
import { ChatCache } from '../src/services/chat/cache.js';
|
||||||
import { MatrixAuthError, MatrixClient } from '../src/services/chat/matrix/client.js';
|
import { MatrixAuthError, MatrixClient } from '../src/services/chat/matrix/client.js';
|
||||||
import { createMockLogger } from './mockLogger.js';
|
import { createMockLogger } from './mockLogger.js';
|
||||||
|
import { authenticatedAccountAuth } from './testUtils.js';
|
||||||
|
|
||||||
const creds = {
|
const creds = {
|
||||||
homeserver: 'https://matrix.example.test',
|
homeserver: 'https://matrix.example.test',
|
||||||
@@ -47,6 +48,7 @@ describe('ChatService username persistence', () => {
|
|||||||
tmpCache(logger),
|
tmpCache(logger),
|
||||||
undefined,
|
undefined,
|
||||||
repo,
|
repo,
|
||||||
|
authenticatedAccountAuth,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(svc.resolveSenderDisplay('@t2_abc:reddit.com')).toBe('alice');
|
expect(svc.resolveSenderDisplay('@t2_abc:reddit.com')).toBe('alice');
|
||||||
@@ -61,6 +63,9 @@ describe('ChatService username persistence', () => {
|
|||||||
logger,
|
logger,
|
||||||
undefined, undefined, undefined, undefined,
|
undefined, undefined, undefined, undefined,
|
||||||
tmpCache(logger),
|
tmpCache(logger),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
authenticatedAccountAuth,
|
||||||
);
|
);
|
||||||
expect(svc.resolveSenderDisplay('@t2_unknown:reddit.com')).toBe('t2_unknown');
|
expect(svc.resolveSenderDisplay('@t2_unknown:reddit.com')).toBe('t2_unknown');
|
||||||
});
|
});
|
||||||
@@ -95,6 +100,7 @@ describe('ChatService username persistence', () => {
|
|||||||
cache,
|
cache,
|
||||||
undefined,
|
undefined,
|
||||||
repo,
|
repo,
|
||||||
|
authenticatedAccountAuth,
|
||||||
);
|
);
|
||||||
|
|
||||||
await svc.loadCache();
|
await svc.loadCache();
|
||||||
@@ -145,6 +151,9 @@ describe('ChatService username persistence', () => {
|
|||||||
tokenRefresher,
|
tokenRefresher,
|
||||||
usernameResolver,
|
usernameResolver,
|
||||||
cache,
|
cache,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
authenticatedAccountAuth,
|
||||||
);
|
);
|
||||||
|
|
||||||
await svc.enrichUsernames();
|
await svc.enrichUsernames();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createControlRouter } from '../src/web/routes/control.js';
|
||||||
|
|
||||||
|
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(servers.splice(0).map(
|
||||||
|
(server) => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bot control account authentication gate', () => {
|
||||||
|
it('refuses to start the bot until full account authentication', async () => {
|
||||||
|
const setBotRunning = vi.fn();
|
||||||
|
const app = express();
|
||||||
|
app.use(createControlRouter({
|
||||||
|
services: { accountAuth: { isFullyAuthenticated: () => false } },
|
||||||
|
setBotRunning,
|
||||||
|
activity: { push: vi.fn() },
|
||||||
|
logger: { warn: vi.fn(), info: vi.fn() },
|
||||||
|
} as never));
|
||||||
|
const server = app.listen(0);
|
||||||
|
servers.push(server);
|
||||||
|
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/start`, { method: 'POST' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(409);
|
||||||
|
expect(await response.json()).toMatchObject({ success: false, authenticated: false });
|
||||||
|
expect(setBotRunning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,9 @@ function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable
|
|||||||
app.use(
|
app.use(
|
||||||
createLoginRouter({
|
createLoginRouter({
|
||||||
services: {
|
services: {
|
||||||
|
accountAuth: {
|
||||||
|
ensureFullyAuthenticated: async () => state === 'authenticated',
|
||||||
|
},
|
||||||
browser: {
|
browser: {
|
||||||
isSessionValid: async () => state === 'authenticated',
|
isSessionValid: async () => state === 'authenticated',
|
||||||
getSessionState: () => state,
|
getSessionState: () => state,
|
||||||
@@ -39,6 +42,20 @@ function appWithBrowser(state: 'authenticated' | 'login-required' | 'unreachable
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appWithPartialAuthentication() {
|
||||||
|
const app = express();
|
||||||
|
app.use(createLoginRouter({
|
||||||
|
services: {
|
||||||
|
accountAuth: { ensureFullyAuthenticated: async () => false },
|
||||||
|
browser: {
|
||||||
|
getSessionState: () => 'authenticated',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger: { error: () => undefined },
|
||||||
|
} as never));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
describe('Reddit session status', () => {
|
describe('Reddit session status', () => {
|
||||||
it('reports when Reddit is unreachable instead of presenting it as logged out', async () => {
|
it('reports when Reddit is unreachable instead of presenting it as logged out', async () => {
|
||||||
const response = await request(appWithBrowser('unreachable'), '/login/status');
|
const response = await request(appWithBrowser('unreachable'), '/login/status');
|
||||||
@@ -60,6 +77,16 @@ describe('Reddit session status', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not report authenticated when only the Reddit browser session is valid', async () => {
|
||||||
|
const response = await request(appWithPartialAuthentication(), '/login/status');
|
||||||
|
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
ready: true,
|
||||||
|
loggedIn: false,
|
||||||
|
status: 'login-required',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('returns the configured public noVNC URL', async () => {
|
it('returns the configured public noVNC URL', async () => {
|
||||||
process.env.NOVNC_PUBLIC_URL = 'https://red-vnc.apps.jbrunton.co.uk';
|
process.env.NOVNC_PUBLIC_URL = 'https://red-vnc.apps.jbrunton.co.uk';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MediaSyncService } from '../src/services/media-sync.js';
|
||||||
|
import { createMockLogger } from './mockLogger.js';
|
||||||
|
|
||||||
|
describe('MediaSyncService account authentication gate', () => {
|
||||||
|
it('does not touch chat while the global account context is unauthenticated', async () => {
|
||||||
|
const chat = {
|
||||||
|
refreshTopChats: vi.fn(),
|
||||||
|
fetchAllMessages: vi.fn(),
|
||||||
|
};
|
||||||
|
const auth = { isFullyAuthenticated: vi.fn(() => false) };
|
||||||
|
const service = new MediaSyncService(
|
||||||
|
chat as never,
|
||||||
|
createMockLogger(),
|
||||||
|
30 * 60 * 1000,
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.triggerSync()).resolves.toBe(false);
|
||||||
|
expect(chat.refreshTopChats).not.toHaveBeenCalled();
|
||||||
|
expect(chat.fetchAllMessages).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ResponseCheckerService } from '../src/services/response-checker.js';
|
||||||
|
import { createMockLogger } from './mockLogger.js';
|
||||||
|
|
||||||
|
describe('ResponseCheckerService account authentication gate', () => {
|
||||||
|
it('does not query repositories or navigate while unauthenticated', async () => {
|
||||||
|
const page = { goto: vi.fn() };
|
||||||
|
const messages = { getUncheckedMessages: vi.fn() };
|
||||||
|
const auth = { isFullyAuthenticated: vi.fn(() => false) };
|
||||||
|
const checker = new ResponseCheckerService(
|
||||||
|
page as never,
|
||||||
|
messages as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
createMockLogger(),
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(checker.checkForResponses()).resolves.toEqual([]);
|
||||||
|
expect(messages.getUncheckedMessages).not.toHaveBeenCalled();
|
||||||
|
expect(page.goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,29 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import type { Page } from 'playwright';
|
import type { Page } from 'playwright';
|
||||||
import { ResponseCheckerService } from '../src/services/response-checker.js';
|
import { ResponseCheckerService as BaseResponseCheckerService } from '../src/services/response-checker.js';
|
||||||
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ProcessedPostRecord } from '../src/database/repositories.js';
|
import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord, ProcessedPostRecord } from '../src/database/repositories.js';
|
||||||
import type { PostsRepository } from '../src/database/repositories.js';
|
import type { PostsRepository } from '../src/database/repositories.js';
|
||||||
import { createMockLogger } from '../tests/mockLogger.js';
|
import { createMockLogger } from '../tests/mockLogger.js';
|
||||||
|
import { authenticatedAccountAuth } from './testUtils.js';
|
||||||
|
|
||||||
|
class ResponseCheckerService extends BaseResponseCheckerService {
|
||||||
|
constructor(
|
||||||
|
page: Page,
|
||||||
|
messagesRepo: MessagesRepository,
|
||||||
|
successfulMessagesRepo: SuccessfulMessagesRepository,
|
||||||
|
postsRepo: PostsRepository,
|
||||||
|
logger: ReturnType<typeof createMockLogger>,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
page,
|
||||||
|
messagesRepo,
|
||||||
|
successfulMessagesRepo,
|
||||||
|
postsRepo,
|
||||||
|
logger,
|
||||||
|
authenticatedAccountAuth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createMockPage(): Page {
|
function createMockPage(): Page {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import type { Config } from '../src/config/index.js';
|
|||||||
import { runMigrations } from '../src/database/client.js';
|
import { runMigrations } from '../src/database/client.js';
|
||||||
import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../src/database/repositories.js';
|
import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../src/database/repositories.js';
|
||||||
|
|
||||||
|
export const authenticatedAccountAuth = {
|
||||||
|
isFullyAuthenticated: () => true,
|
||||||
|
invalidate: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
export function createTestDb(): Database.Database {
|
export function createTestDb(): Database.Database {
|
||||||
const db = new Database(':memory:');
|
const db = new Database(':memory:');
|
||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
|
|||||||
Reference in new issue
Block a user