Skip to content

Commit d107704

Browse files
authored
Merge branch 'main' into release/3.6.59
2 parents 25b4487 + 550d20a commit d107704

8 files changed

Lines changed: 560 additions & 30 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@prosopo/env": patch
3+
"@prosopo/provider": patch
4+
---
5+
6+
Keep the provider admin endpoints working while `MAINTENANCE_MODE` is on. Previously the admin/access-rule router was skipped entirely at boot in maintenance mode — `Environment.isReady()` never connected the DB, so `env.getDb()` threw and the DB-backed `Tasks` couldn't be constructed — which meant adding/removing site keys (access rules), detector keys and decision machines all 404'd on a node in maintenance mode.
7+
8+
Now, in maintenance mode `Environment.isReady()` creates the `ProviderDatabase` handle and connects in the **background** (without awaiting), so a slow or unavailable Mongo/Redis socket still can't gate boot, but `env.getDb()` returns a usable handle and the admin endpoints register and function. The captcha request path is unchanged — it still short-circuits to a maintenance "pass" before touching the DB. `blockMiddleware` now has an explicit maintenance-mode skip (it previously relied on `env.getDb()` throwing to no-op) so the blocklist/Redis lookup stays off the captcha hot path.

packages/env/src/env.ts

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,21 @@ export class Environment implements ProsopoEnvironment {
136136

137137
const maintenanceMode = isMaintenanceMode();
138138

139-
// In maintenance mode we skip the DB connect entirely so a slow
140-
// Mongo socket can't gate boot. Handlers short-circuit before
141-
// touching the DB while the flag is on.
139+
// In maintenance mode the captcha request path is kept DB-free (the
140+
// handlers short-circuit before touching the DB), but the admin
141+
// endpoints — access rules, detector keys, site keys, decision
142+
// machines — still need a DB. So we create the DB handle and connect
143+
// in the BACKGROUND: env.getDb() returns a usable handle (admin
144+
// routes register and keep working), while a slow or unavailable
145+
// Mongo/Redis socket still can't gate boot because we never await
146+
// the connection here.
142147
if (maintenanceMode) {
143148
this.logger.warn(() => ({
144-
msg: "MAINTENANCE_MODE=true — skipping DB import on startup",
149+
msg: "MAINTENANCE_MODE=true — connecting to DB in the background so admin endpoints stay available; captcha path short-circuits",
145150
}));
151+
if (!this.db) {
152+
this.connectDatabaseInBackground();
153+
}
146154
} else if (!this.db) {
147155
await this.importDatabase();
148156
} else if (this.db && !this.db.connected) {
@@ -154,7 +162,9 @@ export class Environment implements ProsopoEnvironment {
154162
}
155163
// Resolve the default datasetId from the DB. Clients no longer
156164
// send one — providers pick from this fallback for image challenges.
157-
if (this.db && !this.datasetId) {
165+
// Skip in maintenance mode: the connection is still settling in the
166+
// background and this is a DB read we don't want to gate boot on.
167+
if (!maintenanceMode && this.db && !this.datasetId) {
158168
try {
159169
this.datasetId = await this.db.getMostRecentDatasetId();
160170
if (this.datasetId) {
@@ -185,25 +195,36 @@ export class Environment implements ProsopoEnvironment {
185195
}
186196
}
187197

198+
// Build the ProviderDatabase handle from config WITHOUT connecting. Returns
199+
// undefined when no database is configured for the current environment.
200+
buildDatabase(): ProviderDatabase | undefined {
201+
if (!this.config.database) {
202+
return undefined;
203+
}
204+
const dbConfig = this.config.database[this.defaultEnvironment];
205+
if (!dbConfig) {
206+
return undefined;
207+
}
208+
return new ProviderDatabase({
209+
mongo: {
210+
url: dbConfig.endpoint,
211+
dbname: dbConfig.dbname,
212+
authSource: dbConfig.authSource,
213+
},
214+
redis: {
215+
url: this.config.redisConnection.url,
216+
password: this.config.redisConnection.password,
217+
},
218+
logger: this.logger,
219+
});
220+
}
221+
188222
async importDatabase(): Promise<void> {
189223
try {
190-
if (this.config.database) {
191-
const dbConfig = this.config.database[this.defaultEnvironment];
192-
if (dbConfig) {
193-
this.db = new ProviderDatabase({
194-
mongo: {
195-
url: dbConfig.endpoint,
196-
dbname: dbConfig.dbname,
197-
authSource: dbConfig.authSource,
198-
},
199-
redis: {
200-
url: this.config.redisConnection.url,
201-
password: this.config.redisConnection.password,
202-
},
203-
logger: this.logger,
204-
});
205-
await this.db.connect();
206-
}
224+
const db = this.buildDatabase();
225+
if (db) {
226+
this.db = db;
227+
await this.db.connect();
207228
}
208229
} catch (error) {
209230
throw new ProsopoEnvError("DATABASE.DATABASE_IMPORT_FAILED", {
@@ -218,6 +239,33 @@ export class Environment implements ProsopoEnvironment {
218239
});
219240
}
220241
}
242+
243+
// Create the DB handle and kick off the connection WITHOUT awaiting it, so
244+
// startup is never gated on the DB socket. Used in maintenance mode: the
245+
// captcha request path never touches the DB (it short-circuits), but the
246+
// admin endpoints do, so the handle must exist. A failed connection is
247+
// logged rather than thrown — admin queries will surface the error (or
248+
// succeed once the socket recovers) when they actually run.
249+
connectDatabaseInBackground(): void {
250+
try {
251+
const db = this.buildDatabase();
252+
if (!db) {
253+
return;
254+
}
255+
this.db = db;
256+
db.connect().catch((error: unknown) => {
257+
this.logger.warn(() => ({
258+
msg: "Background DB connection failed in maintenance mode; admin endpoints will retry on demand",
259+
data: { error },
260+
}));
261+
});
262+
} catch (error) {
263+
this.logger.warn(() => ({
264+
msg: "Failed to create DB handle in maintenance mode; admin endpoints will be unavailable until restart",
265+
data: { error },
266+
}));
267+
}
268+
}
221269
}
222270

223271
// Read directly from process.env to avoid a cyclic dep on the provider

packages/env/src/tests/env.maintenance.unit.test.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,50 @@ describe("Environment.isReady — maintenance mode startup tolerance", () => {
102102
});
103103
});
104104

105-
it("skips DB import entirely when maintenance mode is on", async () => {
105+
it("creates the DB handle and connects in the background when maintenance mode is on", async () => {
106106
process.env.MAINTENANCE_MODE = "true";
107+
const connect = vi.fn().mockResolvedValue(undefined);
108+
mockProviderDatabase.mockImplementation(() => ({
109+
connect,
110+
connected: false,
111+
connection: { readyState: 0 },
112+
}));
107113

108114
const env = buildEnv();
109115
await env.isReady();
116+
110117
expect(env.ready).toBe(true);
111-
// Importantly: we never even construct ProviderDatabase, so a slow
112-
// Mongo socket can't gate boot.
113-
expect(mockProviderDatabase).not.toHaveBeenCalled();
118+
// The handle IS created (and connecting) so admin endpoints — which need
119+
// a DB even during maintenance — keep working. The captcha path stays
120+
// DB-free via per-handler short-circuits, not by leaving env.db unset.
121+
expect(mockProviderDatabase).toHaveBeenCalledTimes(1);
122+
expect(connect).toHaveBeenCalledTimes(1);
123+
expect(env.db).toBeDefined();
114124
expect(mockIpInfoInit).toHaveBeenCalled();
115125
});
116126

127+
it("does not gate boot when the background DB connect fails in maintenance mode", async () => {
128+
process.env.MAINTENANCE_MODE = "true";
129+
const connect = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
130+
mockProviderDatabase.mockImplementation(() => ({
131+
connect,
132+
connected: false,
133+
connection: { readyState: 0 },
134+
}));
135+
136+
const env = buildEnv();
137+
// Unlike the maintenance-off path, a failing connect must NOT reject
138+
// isReady() — the connection is fire-and-forget in the background.
139+
await expect(env.isReady()).resolves.toBeUndefined();
140+
expect(env.ready).toBe(true);
141+
expect(env.db).toBeDefined();
142+
expect(connect).toHaveBeenCalledTimes(1);
143+
// Flush the background rejection so its .catch handler runs within the
144+
// test rather than surfacing as an unhandled rejection afterwards.
145+
await Promise.resolve();
146+
expect(env.logger.warn).toHaveBeenCalled();
147+
});
148+
117149
it("still completes the normal connect path when Mongo is up", async () => {
118150
const connect = vi.fn().mockResolvedValue(undefined);
119151
mockProviderDatabase.mockImplementation(() => ({

packages/provider/src/api/block.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import type { ProviderEnvironment } from "@prosopo/types-env";
1616
import type { NextFunction, Request, Response } from "express";
17+
import { getMaintenanceMode } from "./admin/apiToggleMaintenanceModeEndpoint.js";
1718
import { BlacklistRequestInspector } from "./blacklistRequestInspector.js";
1819

1920
export const blockMiddleware = (providerEnvironment: ProviderEnvironment) => {
@@ -27,6 +28,15 @@ export const blockMiddleware = (providerEnvironment: ProviderEnvironment) => {
2728
providerEnvironment.isReady.bind(providerEnvironment);
2829

2930
return (req: Request, res: Response, next: NextFunction) => {
31+
// In maintenance mode the captcha path short-circuits to a pass and the
32+
// access-rules store (Redis) may be unavailable — skip the blocklist
33+
// check so a slow or down store can't gate requests. env.getDb() now
34+
// returns a handle during maintenance (so the admin endpoints work), so
35+
// this explicit guard — not a thrown getDb() — is what keeps the
36+
// blocklist check off the hot path.
37+
if (getMaintenanceMode()) {
38+
return next();
39+
}
3040
if (!blacklistRequestInspector) {
3141
try {
3242
const db = providerEnvironment.getDb();

packages/provider/src/api/startProviderApi.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,13 @@ export async function startProviderApi(
147147
const apiEndpointAdapter = createApiExpressDefaultEndpointAdapter(
148148
parseLogLevel(env.config.logLevel),
149149
);
150-
// Maintenance-mode / DB-down startup: skip the admin/access-rule wiring
151-
// since both rely on DB-backed Tasks construction. Captcha endpoints
152-
// short-circuit on maintenance-mode at the handler.
150+
// Admin + access-rule routes both rely on a DB-backed Tasks/storage. In
151+
// maintenance mode the DB handle is created and connected in the background
152+
// (see Environment.isReady), so env.getDb() returns a handle and these
153+
// routes register and keep working — that's what keeps admin operations
154+
// (rules, detector keys, site keys, decision machines) available while the
155+
// captcha path short-circuits. The try/catch is a safety net for the case
156+
// where no DB is configured at all.
153157
let apiRuleRoutesProvider: AccessRuleApiRoutes | undefined;
154158
let apiAdminRoutesProvider:
155159
| ReturnType<typeof createApiAdminRoutesProvider>

0 commit comments

Comments
 (0)