diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 705d190..d5f28d0 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -11,6 +11,15 @@ import { AccountSettingsResponseSchema, AdminAuthenticatedUserSchema, AdminOverviewResponseSchema, + AdminServicesResponseSchema, + AdminServiceHealthCheckRequestSchema, + AdminServiceLimitRequestSchema, + AdminServiceParamsSchema, + AdminServiceRecoveryRequestSchema, + ExternalServiceIdSchema, + ExternalServicePeriodTypeSchema, + ExternalServiceStatusSchema, + ExternalServiceUsageSchema, AdminCreditParamsSchema, AdminLoginCompleteRequestSchema, AdminLoginCompleteResponseSchema, @@ -178,6 +187,7 @@ import { import type { RegistrationService } from "./registration.js"; import type { RecentAssetService } from "./recent-assets.js"; import type { AmapAdapter } from "./amap-adapter.js"; +import { ExternalServiceUsageError, type ExternalServiceUsage } from "./external-service-usage.js"; import { ModelConfigurationError } from "./model-configuration.js"; import type { ModelConfigurationService } from "./model-configuration.js"; @@ -222,6 +232,7 @@ export interface CreateAppOptions { resourceId: string; }) => boolean | Promise; registration?: RegistrationService; + serviceUsage?: ExternalServiceUsage; } const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate"); @@ -692,6 +703,15 @@ export async function createApp(options: CreateAppOptions = {}) { AdminLoginCompleteResponseSchema, AdminSessionResponseSchema, AdminOverviewResponseSchema, + AdminServicesResponseSchema, + AdminServiceHealthCheckRequestSchema, + AdminServiceLimitRequestSchema, + AdminServiceParamsSchema, + AdminServiceRecoveryRequestSchema, + ExternalServiceIdSchema, + ExternalServicePeriodTypeSchema, + ExternalServiceStatusSchema, + ExternalServiceUsageSchema, CreditSummarySchema, CreditEntryTypeSchema, CreditEntryStatusSchema, @@ -1064,10 +1084,16 @@ export async function createApp(options: CreateAppOptions = {}) { if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); try { options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); + const usage = options.serviceUsage ?? options.registration.serviceUsage; + usage.claimAmap(); const result = await options.amap.reverseGeocode(request.body as ReverseGeocodeRequest); return { formatted_value: result.formattedValue, service_mode: result.serviceMode, status: "resolved" as const }; } catch (error) { if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error); + if (error instanceof ExternalServiceUsageError) return reply.code(503).send(null); + try { + (options.serviceUsage ?? options.registration.serviceUsage).markProviderFailure({ serviceId: "amap_web_service", reason: "provider_unavailable" }); + } catch { /* preserve the provider failure response */ } return reply.code(503).send(null); } }, @@ -1236,6 +1262,151 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.get( + "/api/v1/admin/services", + { + schema: { + operationId: "getAdminServices", + response: { 200: Type.Ref(AdminServicesResponseSchema), 401: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) }, + tags: ["Admin Services"], + }, + }, + async (request, reply) => { + const registration = options.registration; + const usage = options.serviceUsage ?? registration?.serviceUsage; + if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? registration.readAdminSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + return { + services: usage.readCurrent().map((row) => ({ + hard_limit: row.hardLimit, + pause_reason: row.pauseReason, + period_start: new Date(row.periodStart).toISOString(), + period_type: row.periodType, + service_id: row.serviceId, + service_status: row.status, + updated_at: new Date(row.updatedAt).toISOString(), + used_count: row.usedCount, + })), + }; + }, + ); + + app.post( + "/api/v1/admin/services/:service_id/health-check", + { + attachValidation: true, + schema: { + params: Type.Ref(AdminServiceParamsSchema), + body: Type.Ref(AdminServiceHealthCheckRequestSchema), + headers: Type.Ref(ModelConfigUpdateHeadersSchema), + operationId: "checkAdminServiceHealth", + response: { 200: Type.Object({ check_id: Type.String(), available: Type.Boolean(), checked_at: Type.String() }, { additionalProperties: false }), 400: Type.Ref(ErrorEnvelopeSchema), 401: Type.Ref(ErrorEnvelopeSchema), 403: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) }, + tags: ["Admin Services"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(createErrorEnvelope({ code: "REGISTRATION_REQUEST_INVALID", correlationId: request.id })); + const registration = options.registration; + const usage = options.serviceUsage ?? registration?.serviceUsage; + if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const headers = request.headers as { "x-csrf-token": string }; + registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token }); + const body = request.body as { available: boolean; reason?: string }; + const params = request.params as { service_id: "resend_email" | "amap_web_service" }; + const check = usage.recordHealthCheck({ serviceId: params.service_id, available: body.available, ...(body.reason ? { reason: body.reason } : {}) }); + return { check_id: check.checkId, available: check.available, checked_at: new Date(check.checkedAt).toISOString() }; + } catch (error) { + if (error instanceof RegistrationError) return reply.code(error.httpStatus).send(createErrorEnvelope({ code: error.code, correlationId: request.id })); + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + }, + ); + + app.patch( + "/api/v1/admin/services/:service_id/limits", + { + attachValidation: true, + schema: { + params: Type.Ref(AdminServiceParamsSchema), + body: Type.Ref(AdminServiceLimitRequestSchema), + headers: Type.Ref(ModelConfigUpdateHeadersSchema), + operationId: "updateAdminServiceHardLimit", + response: { 200: Type.Ref(AdminServicesResponseSchema), 400: Type.Ref(ErrorEnvelopeSchema), 401: Type.Ref(ErrorEnvelopeSchema), 403: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) }, + tags: ["Admin Services"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(createErrorEnvelope({ code: "REGISTRATION_REQUEST_INVALID", correlationId: request.id })); + const registration = options.registration; + const usage = options.serviceUsage ?? registration?.serviceUsage; + if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const headers = request.headers as { "x-csrf-token": string }; + const admin = registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token }); + const body = request.body as { hard_limit: number; period_type: "daily" | "monthly" }; + const params = request.params as { service_id: "resend_email" | "amap_web_service" }; + usage.setHardLimit({ actorId: admin.userId, hardLimit: body.hard_limit, periodType: body.period_type, serviceId: params.service_id }); + return { + services: usage.readCurrent().map((row) => ({ + hard_limit: row.hardLimit, + pause_reason: row.pauseReason, + period_start: new Date(row.periodStart).toISOString(), + period_type: row.periodType, + service_id: row.serviceId, + service_status: row.status, + updated_at: new Date(row.updatedAt).toISOString(), + used_count: row.usedCount, + })), + }; + } catch (error) { + if (error instanceof RegistrationError) return reply.code(error.httpStatus).send(createErrorEnvelope({ code: error.code, correlationId: request.id })); + if (error instanceof ExternalServiceUsageError) return reply.code(409).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + }, + ); + + app.post( + "/api/v1/admin/services/:service_id/recover", + { + attachValidation: true, + schema: { + params: Type.Ref(AdminServiceParamsSchema), + body: Type.Ref(AdminServiceRecoveryRequestSchema), + headers: Type.Ref(ModelConfigUpdateHeadersSchema), + operationId: "recoverAdminService", + response: { 200: Type.Object({ status: Type.Literal("active") }, { additionalProperties: false }), 400: Type.Ref(ErrorEnvelopeSchema), 401: Type.Ref(ErrorEnvelopeSchema), 403: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) }, + tags: ["Admin Services"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(createErrorEnvelope({ code: "REGISTRATION_REQUEST_INVALID", correlationId: request.id })); + const registration = options.registration; + const usage = options.serviceUsage ?? registration?.serviceUsage; + if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const headers = request.headers as { "x-csrf-token": string }; + const admin = registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token }); + const body = request.body as { check_id: string }; + const params = request.params as { service_id: "resend_email" | "amap_web_service" }; + return usage.recover({ actorId: admin.userId, checkId: body.check_id, serviceId: params.service_id }); + } catch (error) { + if (error instanceof RegistrationError) return reply.code(error.httpStatus).send(createErrorEnvelope({ code: error.code, correlationId: request.id })); + if (error instanceof ExternalServiceUsageError) return reply.code(409).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + }, + ); + app.post( "/api/v1/auth/login/send", { diff --git a/apps/api/src/external-service-usage.ts b/apps/api/src/external-service-usage.ts new file mode 100644 index 0000000..f2f4b45 --- /dev/null +++ b/apps/api/src/external-service-usage.ts @@ -0,0 +1,459 @@ +import { randomUUID } from "node:crypto"; + +import type BetterSqlite3 from "better-sqlite3"; + +import { serializeAuditSummary } from "./audit-policy.js"; + +export type ExternalServiceId = "resend_email" | "amap_web_service"; +export type ExternalServicePeriodType = "daily" | "monthly"; +export type ExternalServiceStatus = "active" | "paused_quota" | "paused_provider" | "disabled"; + +const retentionMilliseconds = 180 * 24 * 60 * 60 * 1_000; +const recoveryCheckLifetimeMilliseconds = 15 * 60 * 1_000; +const maximumHardLimits: Record>> = { + resend_email: { daily: 80, monthly: 2_400 }, + amap_web_service: { monthly: 1_000 }, +}; + +export interface ExternalServiceUsageRow { + serviceId: ExternalServiceId; + periodType: ExternalServicePeriodType; + periodStart: number; + hardLimit: number; + usedCount: number; + status: ExternalServiceStatus; + pauseReason: string | null; + updatedAt: number; +} + +export class ExternalServiceUsageError extends Error { + constructor( + readonly code: + | "service_paused_quota" + | "service_paused_provider" + | "service_disabled" + | "hard_limit_increase_forbidden" + | "hard_limit_invalid" + | "health_check_required" + | "quota_exhausted" + | "service_not_found", + message = code, + ) { + super(message); + this.name = "ExternalServiceUsageError"; + } +} + +interface ExternalServiceUsageOptions { + clock?: () => number; + database: BetterSqlite3.Database; +} + +interface RawUsageRow { + service_id: ExternalServiceId; + period_type: ExternalServicePeriodType; + period_start: number; + hard_limit: number; + used_count: number; + service_status: ExternalServiceStatus; + pause_reason: string | null; + updated_at: number; +} + +function periodStart(periodType: ExternalServicePeriodType, now: number) { + const date = new Date(now); + if (periodType === "daily") return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1); +} + +function requiredPeriods(serviceId: ExternalServiceId): ExternalServicePeriodType[] { + return serviceId === "resend_email" ? ["daily", "monthly"] : ["monthly"]; +} + +function toPublic(row: RawUsageRow): ExternalServiceUsageRow { + return { + hardLimit: row.hard_limit, + pauseReason: row.pause_reason, + periodStart: row.period_start, + periodType: row.period_type, + serviceId: row.service_id, + status: row.service_status, + updatedAt: row.updated_at, + usedCount: row.used_count, + }; +} + +export class ExternalServiceUsage { + readonly database: BetterSqlite3.Database; + readonly clock: () => number; + + constructor(options: ExternalServiceUsageOptions) { + this.database = options.database; + this.clock = options.clock ?? Date.now; + this.ensureSchema(); + this.runImmediate(() => { + this.ensureCurrentRows(this.clock(), "resend_email"); + this.ensureCurrentRows(this.clock(), "amap_web_service"); + }); + } + + claimResend(now = this.clock()) { + return this.runImmediate(() => this.claimWithinTransaction("resend_email", now)); + } + + claimResendWithinTransaction(now = this.clock()) { + return this.claimWithinTransaction("resend_email", now); + } + + claimAmap(now = this.clock()) { + return this.runImmediate(() => this.claimWithinTransaction("amap_web_service", now)); + } + + claimAmapWithinTransaction(now = this.clock()) { + return this.claimWithinTransaction("amap_web_service", now); + } + + markProviderFailure(input: { serviceId: ExternalServiceId; reason: string; now?: number }) { + return this.runImmediate(() => this.markProviderFailureWithinTransaction(input)); + } + + markProviderFailureWithinTransaction(input: { serviceId: ExternalServiceId; reason: string; now?: number }) { + const now = input.now ?? this.clock(); + const rows = this.ensureCurrentRows(now, input.serviceId); + const reason = normalizeReason(input.reason); + for (const row of rows) { + if (row.service_status === "disabled") continue; + this.database.prepare(` + UPDATE external_service_usage + SET service_status = 'paused_provider', pause_reason = ?, updated_at = ? + WHERE service_id = ? AND period_type = ? AND period_start = ? + `).run(reason, now, row.service_id, row.period_type, row.period_start); + } + this.recordAudit({ + actorRef: "external_service_runtime", + actorType: "system", + afterSummary: { pause_reason: reason, status: "paused_provider" }, + beforeSummary: { status: rows[0]?.service_status ?? "active" }, + operationType: "service_provider_pause", + result: "succeeded", + targetRef: input.serviceId, + targetType: "external_service", + }, now); + return this.read(input.serviceId); + } + + recordHealthCheck(input: { serviceId: ExternalServiceId; available: boolean; reason?: string; now?: number }) { + const now = input.now ?? this.clock(); + const checkId = randomUUID(); + const currentPeriod = periodStart(requiredPeriods(input.serviceId)[0]!, now); + const result = this.runImmediate(() => { + this.database.prepare(` + INSERT INTO service_recovery_checks ( + check_id, service_name, target_ref, status, checked_at, expires_at, details_json, + service_id, period_start, available, check_reason + ) VALUES (?, 'external_service', ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + checkId, + input.serviceId, + input.available ? "passed" : "failed", + now, + now + recoveryCheckLifetimeMilliseconds, + JSON.stringify({ non_sensitive: true }), + input.serviceId, + currentPeriod, + input.available ? 1 : 0, + input.reason ? normalizeReason(input.reason) : null, + ); + return { checkId, available: input.available, checkedAt: now }; + }); + return result; + } + + recover(input: { serviceId: ExternalServiceId; actorId: string; checkId: string; now?: number }) { + const now = input.now ?? this.clock(); + const result = this.runImmediate(() => { + const check = this.database.prepare(` + SELECT check_id, period_start, available, expires_at + FROM service_recovery_checks + WHERE check_id = ? AND service_id = ? AND service_name = 'external_service' + `).get(input.checkId, input.serviceId) as { available: number; check_id: string; expires_at: number; period_start: number } | undefined; + const currentPeriod = periodStart(requiredPeriods(input.serviceId)[0]!, now); + if (!check?.available || check.period_start !== currentPeriod || check.expires_at <= now) { + this.recordAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { reason: "health_check_required" }, + beforeSummary: null, + operationType: "service_recovery", + result: "failed", + targetRef: input.serviceId, + targetType: "external_service", + }, now); + return { error: new ExternalServiceUsageError("health_check_required") }; + } + const rows = this.ensureCurrentRows(now, input.serviceId); + if (rows.some((row) => row.used_count >= row.hard_limit)) { + this.recordAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { reason: "quota_exhausted" }, + beforeSummary: { status: rows[0]?.service_status ?? "paused_quota" }, + operationType: "service_recovery", + result: "failed", + targetRef: input.serviceId, + targetType: "external_service", + }, now); + return { error: new ExternalServiceUsageError("quota_exhausted") }; + } + for (const row of rows) { + this.database.prepare(` + UPDATE external_service_usage + SET service_status = 'active', pause_reason = NULL, updated_at = ? + WHERE service_id = ? AND period_type = ? AND period_start = ? + `).run(now, row.service_id, row.period_type, row.period_start); + } + this.recordAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { check_id: input.checkId, status: "active" }, + beforeSummary: { status: rows[0]?.service_status ?? "paused_provider" }, + operationType: "service_recovery", + result: "succeeded", + targetRef: input.serviceId, + targetType: "external_service", + }, now); + return { status: "active" as const }; + }); + if ("error" in result && result.error) throw result.error; + return result; + } + + setHardLimit(input: { + serviceId: ExternalServiceId; + periodType: ExternalServicePeriodType; + hardLimit: number; + actorId: string; + now?: number; + }) { + const now = input.now ?? this.clock(); + const result = this.runImmediate(() => { + const maximum = maximumHardLimits[input.serviceId][input.periodType]; + const current = this.ensureCurrentRows(now, input.serviceId).find((row) => row.period_type === input.periodType); + if (current && input.hardLimit > current.hard_limit) { + this.recordAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { reason: "hard_limit_increase_forbidden" }, + beforeSummary: { hard_limit: current.hard_limit }, + operationType: "service_hard_limit_update", + result: "failed", + targetRef: `${input.serviceId}:${input.periodType}`, + targetType: "external_service_limit", + }, now); + return { error: new ExternalServiceUsageError("hard_limit_increase_forbidden") }; + } + const valid = maximum !== undefined && Number.isSafeInteger(input.hardLimit) && input.hardLimit >= 1 && input.hardLimit <= maximum; + if (!valid || !current) { + this.recordAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { reason: "hard_limit_invalid" }, + beforeSummary: current ? { hard_limit: current.hard_limit } : null, + operationType: "service_hard_limit_update", + result: "failed", + targetRef: `${input.serviceId}:${input.periodType}`, + targetType: "external_service_limit", + }, now); + return { error: new ExternalServiceUsageError("hard_limit_invalid") }; + } + this.database.prepare(` + UPDATE external_service_usage + SET hard_limit = ?, service_status = CASE + WHEN used_count >= ? THEN 'paused_quota' + ELSE service_status + END, pause_reason = CASE + WHEN used_count >= ? THEN 'hard_limit_reached' + ELSE pause_reason + END, updated_at = ? + WHERE service_id = ? AND period_type = ? AND period_start = ? + `).run(input.hardLimit, input.hardLimit, input.hardLimit, now, current.service_id, current.period_type, current.period_start); + this.recordAudit({ + actorRef: input.actorId, + actorType: "super_admin", + afterSummary: { hard_limit: input.hardLimit }, + beforeSummary: { hard_limit: current.hard_limit }, + operationType: "service_hard_limit_update", + result: "succeeded", + targetRef: `${input.serviceId}:${input.periodType}`, + targetType: "external_service_limit", + }, now); + return this.read(input.serviceId); + }); + if ("error" in result && result.error) throw result.error; + return result; + } + + read(serviceId?: ExternalServiceId) { + const rows = (serviceId + ? this.database.prepare("SELECT * FROM external_service_usage WHERE service_id = ? ORDER BY period_type").all(serviceId) + : this.database.prepare("SELECT * FROM external_service_usage ORDER BY service_id, period_type").all()) as RawUsageRow[]; + return rows.map(toPublic); + } + + readCurrent() { + const now = this.clock(); + return this.read().filter((row) => row.periodStart === periodStart(row.periodType, now)); + } + + readStatus(serviceId: ExternalServiceId) { + const rows = this.read(serviceId).filter((row) => row.periodStart >= periodStart(row.periodType, this.clock())); + const status = rows.some((row) => row.status === "disabled") + ? "disabled" + : rows.some((row) => row.status === "paused_provider") + ? "paused_provider" + : rows.some((row) => row.status === "paused_quota") + ? "paused_quota" + : "active"; + return { serviceId, status, rows } as const; + } + + private claimWithinTransaction(serviceId: ExternalServiceId, now: number) { + const rows = this.ensureCurrentRows(now, serviceId); + for (const row of rows) { + if (row.service_status === "paused_quota") throw new ExternalServiceUsageError("service_paused_quota"); + if (row.service_status === "paused_provider") throw new ExternalServiceUsageError("service_paused_provider"); + if (row.service_status === "disabled") throw new ExternalServiceUsageError("service_disabled"); + if (row.used_count >= row.hard_limit) { + this.database.prepare(` + UPDATE external_service_usage + SET service_status = 'paused_quota', pause_reason = 'hard_limit_reached', updated_at = ? + WHERE service_id = ? AND period_type = ? AND period_start = ? + `).run(now, row.service_id, row.period_type, row.period_start); + throw new ExternalServiceUsageError("service_paused_quota"); + } + } + const updated = rows.map((row) => { + const usedCount = row.used_count + 1; + const status: ExternalServiceStatus = usedCount >= row.hard_limit ? "paused_quota" : "active"; + this.database.prepare(` + UPDATE external_service_usage + SET used_count = ?, service_status = ?, pause_reason = CASE WHEN ? = 'active' THEN NULL ELSE 'hard_limit_reached' END, updated_at = ? + WHERE service_id = ? AND period_type = ? AND period_start = ? + `).run(usedCount, status, status, now, row.service_id, row.period_type, row.period_start); + return { periodType: row.period_type, remaining: Math.max(0, row.hard_limit - usedCount), usedCount }; + }); + return { allowed: true as const, serviceId, allocations: updated, remaining: Math.min(...updated.map((item) => item.remaining)) }; + } + + private ensureCurrentRows(now: number, serviceId: ExternalServiceId) { + const periods = requiredPeriods(serviceId); + for (const periodType of periods) { + const start = periodStart(periodType, now); + const current = this.database.prepare(` + SELECT * FROM external_service_usage WHERE service_id = ? AND period_type = ? AND period_start = ? + `).get(serviceId, periodType, start) as RawUsageRow | undefined; + if (current) continue; + const previous = this.database.prepare(` + SELECT hard_limit FROM external_service_usage + WHERE service_id = ? AND period_type = ? ORDER BY period_start DESC LIMIT 1 + `).get(serviceId, periodType) as { hard_limit: number } | undefined; + const maximum = maximumHardLimits[serviceId][periodType]; + if (maximum === undefined) throw new ExternalServiceUsageError("service_not_found"); + this.database.prepare(` + INSERT INTO external_service_usage ( + service_id, period_type, period_start, hard_limit, used_count, + service_status, pause_reason, updated_at + ) VALUES (?, ?, ?, ?, 0, ?, ?, ?) + `).run(serviceId, periodType, start, previous?.hard_limit ?? maximum, previous ? "paused_quota" : "active", previous ? "period_confirmation_required" : null, now); + } + return this.database.prepare(` + SELECT * FROM external_service_usage + WHERE service_id = ? AND period_start IN (${periods.map(() => "?").join(",")}) + ORDER BY period_type + `).all(serviceId, ...periods.map((period) => periodStart(period, now))) as RawUsageRow[]; + } + + private ensureSchema() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS external_service_usage ( + service_id TEXT NOT NULL CHECK (service_id IN ('resend_email', 'amap_web_service')), + period_type TEXT NOT NULL CHECK (period_type IN ('daily', 'monthly')), + period_start INTEGER NOT NULL, + hard_limit INTEGER NOT NULL CHECK (hard_limit >= 1), + used_count INTEGER NOT NULL CHECK (used_count >= 0 AND used_count <= hard_limit), + service_status TEXT NOT NULL CHECK (service_status IN ('active', 'paused_quota', 'paused_provider', 'disabled')), + pause_reason TEXT, + updated_at INTEGER NOT NULL, + PRIMARY KEY (service_id, period_type, period_start) + ); + CREATE TABLE IF NOT EXISTS service_recovery_checks ( + check_id TEXT PRIMARY KEY, + service_name TEXT NOT NULL DEFAULT 'external_service', + target_ref TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'passed' CHECK (status IN ('passed', 'failed')), + checked_at INTEGER NOT NULL DEFAULT 0, + expires_at INTEGER NOT NULL DEFAULT 0, + details_json TEXT NOT NULL DEFAULT '{}', + service_id TEXT CHECK (service_id IS NULL OR service_id IN ('resend_email', 'amap_web_service')), + period_start INTEGER, + available INTEGER CHECK (available IS NULL OR available IN (0, 1)), + check_reason TEXT + ); + `); + const columns = new Set((this.database.prepare("PRAGMA table_info(service_recovery_checks)").all() as Array<{ name: string }>).map((column) => column.name)); + const additions: Array<[string, string]> = [ + ["service_name", "TEXT NOT NULL DEFAULT 'external_service'"], + ["target_ref", "TEXT NOT NULL DEFAULT ''"], + ["status", "TEXT NOT NULL DEFAULT 'passed'"], + ["expires_at", "INTEGER NOT NULL DEFAULT 0"], + ["details_json", "TEXT NOT NULL DEFAULT '{}'"], + ["service_id", "TEXT"], + ["period_start", "INTEGER"], + ["available", "INTEGER"], + ["check_reason", "TEXT"], + ]; + for (const [name, definition] of additions) { + if (!columns.has(name)) this.database.exec(`ALTER TABLE service_recovery_checks ADD COLUMN ${name} ${definition}`); + } + } + + private runImmediate(action: () => T): T { + const nested = this.database.inTransaction; + if (!nested) this.database.exec("BEGIN IMMEDIATE"); + try { + const result = action(); + if (!nested) this.database.exec("COMMIT"); + return result; + } catch (error) { + if (!nested && this.database.inTransaction) this.database.exec("ROLLBACK"); + throw error; + } + } + + private recordAudit(input: { + actorRef: string; + actorType: "system" | "super_admin"; + afterSummary: Record | null; + beforeSummary: Record | null; + operationType: string; + result: "succeeded" | "failed"; + targetRef: string; + targetType: string; + }, now: number) { + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + randomUUID(), input.actorType, input.actorRef, input.operationType, input.targetType, input.targetRef, + input.result, serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary), now, + now + retentionMilliseconds, + ); + } +} + +function normalizeReason(reason: string) { + const normalized = reason.trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "_").slice(0, 120); + return normalized || "provider_unavailable"; +} diff --git a/apps/api/src/registration.ts b/apps/api/src/registration.ts index 74fa9f0..cf505a9 100644 --- a/apps/api/src/registration.ts +++ b/apps/api/src/registration.ts @@ -11,6 +11,7 @@ import { serializeAuditSummary, } from "./audit-policy.js"; import type { ResendAdapter } from "./resend-adapter.js"; +import { ExternalServiceUsage } from "./external-service-usage.js"; import { RegistrationError, type RegistrationErrorReason, @@ -226,6 +227,7 @@ function constantTimeTextEqual(left: string, right: string) { export class RegistrationService { readonly database: BetterSqlite3.Database; + readonly serviceUsage: ExternalServiceUsage; readonly options: Required> & RegistrationServiceOptions; private adminAllowlistHashes = new Set(); private privacyPurgeActive = false; @@ -254,6 +256,7 @@ export class RegistrationService { this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0); this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0); this.migrate(); + this.serviceUsage = new ExternalServiceUsage({ database: this.database, clock: this.options.clock }); } close() { @@ -299,6 +302,7 @@ export class RegistrationService { if (existing) throw new RegistrationError("AUTH_ENTRY_REJECTED", "registration_login_required"); this.assertChallengeSendAllowed(email, "register", "registration", now); this.recordRateSend(email, "registration", now); + this.serviceUsage.claimResendWithinTransaction(now); this.database.prepare(` INSERT INTO email_challenges ( @@ -328,6 +332,7 @@ export class RegistrationService { try { await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "register" }); } catch { + this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now }); this.runImmediate("registration_send_compensation", () => { this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); return { outcome: "committed", value: undefined }; @@ -355,6 +360,7 @@ export class RegistrationService { if (user.role !== "user") throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_admin_required"); this.assertChallengeSendAllowed(email, "login", clientKey, now); this.recordRateSend(email, clientKey, now); + this.serviceUsage.claimResendWithinTransaction(now); this.database.prepare(` INSERT INTO email_challenges ( challenge_id, email, invite_id, code_hmac, purpose, expires_at, @@ -382,6 +388,7 @@ export class RegistrationService { try { await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "login" }); } catch { + this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now }); this.runImmediate("registration_send_compensation", () => { this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); return { outcome: "committed", value: undefined }; @@ -768,6 +775,7 @@ export class RegistrationService { } this.assertChallengeSendAllowed(email, "admin_login", clientKey, now); this.recordRateSend(email, clientKey, now); + this.serviceUsage.claimResendWithinTransaction(now); this.database.prepare(` INSERT INTO email_challenges ( challenge_id, email, invite_id, code_hmac, purpose, expires_at, @@ -795,6 +803,7 @@ export class RegistrationService { try { await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "admin_login" }); } catch { + this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now }); this.runImmediate("registration_send_compensation", () => { this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); this.recordAdminLoginRejection("service_unavailable", now); @@ -1097,6 +1106,7 @@ export class RegistrationService { now + resendDelayMilliseconds, now, ); + this.serviceUsage.claimResendWithinTransaction(now); return { outcome: "committed", value: { @@ -1116,6 +1126,7 @@ export class RegistrationService { purpose: "account_delete", }); } catch { + this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now }); this.runImmediate("registration_send_compensation", () => { this.database.prepare("DELETE FROM account_deletion_challenges WHERE deletion_id = ? AND consumed_at IS NULL").run(deletionId); return { outcome: "committed", value: undefined }; diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index 0cc508a..ac8eb7c 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminOverviewResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; +import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AdminServiceHealthCheckRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminOverviewResponse, AdminServicesResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, AdminServiceRecoveryRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest, AdminServiceLimitRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } @@ -13,6 +13,23 @@ export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, opti return response.json() as Promise; } +export async function checkAdminServiceHealth(body: AdminServiceHealthCheckRequest, options: ClientOptions = {}): Promise<{ + "available": boolean; + "check_id": string; + "checked_at": string; +}> { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services/{service_id}/health-check`, { body: JSON.stringify(body), method: "POST", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise<{ + "available": boolean; + "check_id": string; + "checked_at": string; +}>; +} + export async function checkBrowserSupport(body: BrowserSupportRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); @@ -94,6 +111,13 @@ export async function getAdminOverview(options: ClientOptions = {}): Promise; } +export async function getAdminServices(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function getAdminSession(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} }); @@ -212,6 +236,19 @@ export async function recordRecentAsset(body: RecentAssetRecordRequest, options: return response.json() as Promise; } +export async function recoverAdminService(body: AdminServiceRecoveryRequest, options: ClientOptions = {}): Promise<{ + "status": "active"; +}> { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services/{service_id}/recover`, { body: JSON.stringify(body), method: "POST", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise<{ + "status": "active"; +}>; +} + export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); @@ -321,3 +358,12 @@ export async function updateAccountProfile(body: AccountProfileUpdateRequest, op if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json() as Promise; } + +export async function updateAdminServiceHardLimit(body: AdminServiceLimitRequest, options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services/{service_id}/limits`, { body: JSON.stringify(body), method: "PATCH", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index 4387041..18f67ec 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -117,6 +117,28 @@ export type AdminOverviewResponse = { }; }; +export type AdminServiceHealthCheckRequest = { + "available": boolean; + "reason"?: string; +}; + +export type AdminServiceLimitRequest = { + "hard_limit": number; + "period_type": ExternalServicePeriodType; +}; + +export type AdminServiceParams = { + "service_id": ExternalServiceId; +}; + +export type AdminServiceRecoveryRequest = { + "check_id": string; +}; + +export type AdminServicesResponse = { + "services": Array; +}; + export type AdminSessionResponse = { "acknowledged_private_content_notice_version": string | null; "admin": AdminAuthenticatedUser; @@ -356,6 +378,23 @@ export type ErrorEnvelope = { export type ExportFormat = "jpg" | "png"; +export type ExternalServiceId = "resend_email" | "amap_web_service"; + +export type ExternalServicePeriodType = "daily" | "monthly"; + +export type ExternalServiceStatus = "active" | "paused_quota" | "paused_provider" | "disabled"; + +export type ExternalServiceUsage = { + "hard_limit": number; + "pause_reason": string | null; + "period_start": string; + "period_type": ExternalServicePeriodType; + "service_id": ExternalServiceId; + "service_status": ExternalServiceStatus; + "updated_at": string; + "used_count": number; +}; + export type FailedEmptyTrashRequest = { "project_ids": Array; }; diff --git a/openapi/openapi.json b/openapi/openapi.json index 39835dc..e7300e2 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -696,6 +696,83 @@ ], "type": "object" }, + "AdminServiceHealthCheckRequest": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "reason": { + "maxLength": 120, + "pattern": "^[a-zA-Z0-9_. -]+$", + "type": "string" + } + }, + "required": [ + "available" + ], + "type": "object" + }, + "AdminServiceLimitRequest": { + "additionalProperties": false, + "properties": { + "hard_limit": { + "minimum": 1, + "type": "integer" + }, + "period_type": { + "$ref": "#/components/schemas/ExternalServicePeriodType" + } + }, + "required": [ + "period_type", + "hard_limit" + ], + "type": "object" + }, + "AdminServiceParams": { + "additionalProperties": false, + "properties": { + "service_id": { + "$ref": "#/components/schemas/ExternalServiceId" + } + }, + "required": [ + "service_id" + ], + "type": "object" + }, + "AdminServiceRecoveryRequest": { + "additionalProperties": false, + "properties": { + "check_id": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[0-9a-fA-F-]+$", + "type": "string" + } + }, + "required": [ + "check_id" + ], + "type": "object" + }, + "AdminServicesResponse": { + "additionalProperties": false, + "properties": { + "services": { + "items": { + "$ref": "#/components/schemas/ExternalServiceUsage" + }, + "maxItems": 3, + "type": "array" + } + }, + "required": [ + "services" + ], + "type": "object" + }, "AdminSessionResponse": { "additionalProperties": false, "properties": { @@ -2450,6 +2527,119 @@ } ] }, + "ExternalServiceId": { + "anyOf": [ + { + "enum": [ + "resend_email" + ], + "type": "string" + }, + { + "enum": [ + "amap_web_service" + ], + "type": "string" + } + ] + }, + "ExternalServicePeriodType": { + "anyOf": [ + { + "enum": [ + "daily" + ], + "type": "string" + }, + { + "enum": [ + "monthly" + ], + "type": "string" + } + ] + }, + "ExternalServiceStatus": { + "anyOf": [ + { + "enum": [ + "active" + ], + "type": "string" + }, + { + "enum": [ + "paused_quota" + ], + "type": "string" + }, + { + "enum": [ + "paused_provider" + ], + "type": "string" + }, + { + "enum": [ + "disabled" + ], + "type": "string" + } + ] + }, + "ExternalServiceUsage": { + "additionalProperties": false, + "properties": { + "hard_limit": { + "minimum": 1, + "type": "integer" + }, + "pause_reason": { + "anyOf": [ + { + "maxLength": 120, + "pattern": "^[a-z0-9_.-]+$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "period_start": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", + "type": "string" + }, + "period_type": { + "$ref": "#/components/schemas/ExternalServicePeriodType" + }, + "service_id": { + "$ref": "#/components/schemas/ExternalServiceId" + }, + "service_status": { + "$ref": "#/components/schemas/ExternalServiceStatus" + }, + "updated_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", + "type": "string" + }, + "used_count": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "service_id", + "period_type", + "period_start", + "hard_limit", + "used_count", + "service_status", + "pause_reason", + "updated_at" + ], + "type": "object" + }, "FailedEmptyTrashRequest": { "additionalProperties": false, "properties": { @@ -8331,6 +8521,441 @@ ] } }, + "/api/v1/admin/services": { + "get": { + "operationId": "getAdminServices", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminServicesResponse" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Admin Services" + ] + } + }, + "/api/v1/admin/services/{service_id}/health-check": { + "post": { + "operationId": "checkAdminServiceHealth", + "parameters": [ + { + "in": "path", + "name": "service_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/ExternalServiceId" + } + }, + { + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminServiceHealthCheckRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "check_id": { + "type": "string" + }, + "checked_at": { + "type": "string" + } + }, + "required": [ + "check_id", + "available", + "checked_at" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Admin Services" + ] + } + }, + "/api/v1/admin/services/{service_id}/limits": { + "patch": { + "operationId": "updateAdminServiceHardLimit", + "parameters": [ + { + "in": "path", + "name": "service_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/ExternalServiceId" + } + }, + { + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminServiceLimitRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminServicesResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Admin Services" + ] + } + }, + "/api/v1/admin/services/{service_id}/recover": { + "post": { + "operationId": "recoverAdminService", + "parameters": [ + { + "in": "path", + "name": "service_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/ExternalServiceId" + } + }, + { + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminServiceRecoveryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "active" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Admin Services" + ] + } + }, "/api/v1/admin/users/{userId}/credit-adjustments": { "post": { "operationId": "adjustAdminUserCredits", diff --git a/packages/shared-contracts/src/index.ts b/packages/shared-contracts/src/index.ts index 3cbbd6e..b33ff35 100644 --- a/packages/shared-contracts/src/index.ts +++ b/packages/shared-contracts/src/index.ts @@ -1,6 +1,7 @@ export { Type } from "@sinclair/typebox"; export * from "./api.js"; export * from "./admin.js"; +export * from "./services.js"; export * from "./assets.js"; export * from "./auth.js"; export * from "./bootstrap.js"; diff --git a/packages/shared-contracts/src/services.ts b/packages/shared-contracts/src/services.ts new file mode 100644 index 0000000..d625729 --- /dev/null +++ b/packages/shared-contracts/src/services.ts @@ -0,0 +1,62 @@ +import { Type, type Static } from "@sinclair/typebox"; + +const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"; + +export const ExternalServiceIdSchema = Type.Union([ + Type.Literal("resend_email"), + Type.Literal("amap_web_service"), +], { $id: "ExternalServiceId" }); + +export const ExternalServicePeriodTypeSchema = Type.Union([ + Type.Literal("daily"), + Type.Literal("monthly"), +], { $id: "ExternalServicePeriodType" }); + +export const ExternalServiceStatusSchema = Type.Union([ + Type.Literal("active"), + Type.Literal("paused_quota"), + Type.Literal("paused_provider"), + Type.Literal("disabled"), +], { $id: "ExternalServiceStatus" }); + +export const ExternalServiceUsageSchema = Type.Object({ + service_id: Type.Ref(ExternalServiceIdSchema), + period_type: Type.Ref(ExternalServicePeriodTypeSchema), + period_start: Type.String({ pattern: isoTimestampPattern }), + hard_limit: Type.Integer({ minimum: 1 }), + used_count: Type.Integer({ minimum: 0 }), + service_status: Type.Ref(ExternalServiceStatusSchema), + pause_reason: Type.Union([Type.String({ maxLength: 120, pattern: "^[a-z0-9_.-]+$" }), Type.Null()]), + updated_at: Type.String({ pattern: isoTimestampPattern }), +}, { additionalProperties: false, $id: "ExternalServiceUsage" }); + +export const AdminServicesResponseSchema = Type.Object({ + services: Type.Array(Type.Ref(ExternalServiceUsageSchema), { maxItems: 3 }), +}, { additionalProperties: false, $id: "AdminServicesResponse" }); + +export const AdminServiceLimitRequestSchema = Type.Object({ + period_type: Type.Ref(ExternalServicePeriodTypeSchema), + hard_limit: Type.Integer({ minimum: 1 }), +}, { additionalProperties: false, $id: "AdminServiceLimitRequest" }); + +export const AdminServiceHealthCheckRequestSchema = Type.Object({ + available: Type.Boolean(), + reason: Type.Optional(Type.String({ maxLength: 120, pattern: "^[a-zA-Z0-9_. -]+$" })), +}, { additionalProperties: false, $id: "AdminServiceHealthCheckRequest" }); + +export const AdminServiceRecoveryRequestSchema = Type.Object({ + check_id: Type.String({ maxLength: 64, minLength: 1, pattern: "^[0-9a-fA-F-]+$" }), +}, { additionalProperties: false, $id: "AdminServiceRecoveryRequest" }); + +export const AdminServiceParamsSchema = Type.Object({ + service_id: Type.Ref(ExternalServiceIdSchema), +}, { additionalProperties: false, $id: "AdminServiceParams" }); + +export type ExternalServiceId = Static; +export type ExternalServicePeriodType = Static; +export type ExternalServiceUsage = Static; +export type AdminServicesResponse = Static; +export type AdminServiceLimitRequest = Static; +export type AdminServiceHealthCheckRequest = Static; +export type AdminServiceRecoveryRequest = Static; +export type AdminServiceParams = Static; diff --git a/tests/api/wp6-03-services.test.ts b/tests/api/wp6-03-services.test.ts new file mode 100644 index 0000000..ce5d157 --- /dev/null +++ b/tests/api/wp6-03-services.test.ts @@ -0,0 +1,157 @@ +import { randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { ExternalServiceUsage, ExternalServiceUsageError } from "../../apps/api/src/external-service-usage.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; +import { createApp } from "../../apps/api/src/app.js"; +import { MockAmapAdapter } from "../../apps/api/src/amap-adapter.js"; + +const roots: string[] = []; +const registrations: RegistrationService[] = []; + +afterEach(() => { + for (const registration of registrations.splice(0)) registration.close(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function createRegistration(now = Date.parse("2026-08-04T09:00:00.000Z")) { + const root = mkdtempSync(join(tmpdir(), "dada-wp6-03-services-")); + roots.push(root); + const registration = new RegistrationService({ + adminAllowlistPepper: Buffer.alloc(32, 0x91), + challengePepper: Buffer.alloc(32, 0x92), + clock: () => now, + currentPrivacyNoticeVersion: "p0a-registration-notice-v1", + databasePath: join(root, "dada.sqlite3"), + invitePepper: Buffer.alloc(32, 0x93), + resend: new MockResendAdapter(), + sessionPepper: Buffer.alloc(32, 0x94), + }); + registrations.push(registration); + return registration; +} + +function seedAdmin(registration: RegistrationService, now = Date.parse("2026-08-04T09:00:00.000Z")) { + const userId = randomUUID(); + registration.database.prepare(` + INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at) + VALUES (?, ?, 'super_admin', 'active', 0, ?, ?) + `).run(userId, `${userId}@example.invalid`, randomUUID(), now); + registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId); + return userId; +} + +function seedUser(registration: RegistrationService, now = Date.parse("2026-08-04T09:00:00.000Z")) { + const userId = randomUUID(); + registration.database.prepare(` + INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at) + VALUES (?, ?, 'user', 'active', 1, ?, ?) + `).run(userId, `${userId}@example.invalid`, randomUUID(), now); + registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Test User', '@test_user')").run(userId); + registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now); + return userId; +} + +describe("TDD-WP6-SVC-001 quota hard stop", () => { + it("claims Resend daily and monthly quotas transactionally and pauses before the next call", () => { + const registration = createRegistration(); + const usage = registration.serviceUsage; + usage.setHardLimit({ serviceId: "resend_email", periodType: "daily", hardLimit: 2, actorId: "fixture-admin" }); + usage.setHardLimit({ serviceId: "resend_email", periodType: "monthly", hardLimit: 2, actorId: "fixture-admin" }); + + expect(usage.claimResend()).toMatchObject({ allowed: true, remaining: 1 }); + expect(usage.claimResend()).toMatchObject({ allowed: true, remaining: 0 }); + expect(() => usage.claimResend()).toThrowError(ExternalServiceUsageError); + expect(usage.read("resend_email").every((row) => row.status === "paused_quota")).toBe(true); + }); + + it("does not call a paused provider, and requires a successful health check before recovery", () => { + const registration = createRegistration(); + const usage = registration.serviceUsage; + usage.markProviderFailure({ serviceId: "amap_web_service", reason: "provider_unavailable" }); + expect(() => usage.claimAmap()).toThrowError(ExternalServiceUsageError); + expect(() => usage.recover({ serviceId: "amap_web_service", actorId: randomUUID(), checkId: randomUUID() })) + .toThrowError(/health_check_required/); + + const check = usage.recordHealthCheck({ serviceId: "amap_web_service", available: true, reason: "ok" }); + expect(usage.recover({ serviceId: "amap_web_service", actorId: randomUUID(), checkId: check.checkId })).toMatchObject({ status: "active" }); + }); + + it("keeps a new free period paused until an administrator confirms it", () => { + const firstNow = Date.parse("2026-08-04T23:59:00.000Z"); + const registration = createRegistration(firstNow); + registration.serviceUsage.claimAmap(firstNow); + const nextPeriod = new ExternalServiceUsage({ + database: registration.database, + clock: () => Date.parse("2026-09-01T00:01:00.000Z"), + }); + expect(() => nextPeriod.claimAmap()).toThrowError(/service_paused_quota/); + expect(nextPeriod.read("amap_web_service").find((row) => row.periodStart === Date.parse("2026-08-01T00:00:00.000Z"))?.status).toBe("active"); + expect(nextPeriod.read("amap_web_service").find((row) => row.periodStart === Date.parse("2026-09-01T00:00:00.000Z"))?.status).toBe("paused_quota"); + }); + + it("rejects hard-limit increases and records non-sensitive admin audit", () => { + const registration = createRegistration(); + const usage = registration.serviceUsage; + expect(() => usage.setHardLimit({ serviceId: "amap_web_service", periodType: "monthly", hardLimit: 1001, actorId: randomUUID() })) + .toThrowError(/hard_limit_increase_forbidden/); + expect(registration.database.prepare("SELECT COUNT(*) AS count FROM admin_operation_logs WHERE operation_type = 'service_hard_limit_update'").get()) + .toMatchObject({ count: 1 }); + }); + + it("blocks the 81st Resend attempt before the adapter and exposes only current rows to admins", async () => { + let now = Date.parse("2026-08-04T09:00:00.000Z"); + const registration = createRegistration(now); + const resend = registration.options.resend as MockResendAdapter; + registration.serviceUsage.setHardLimit({ serviceId: "resend_email", periodType: "daily", hardLimit: 1, actorId: "fixture-admin" }); + registration.serviceUsage.setHardLimit({ serviceId: "resend_email", periodType: "monthly", hardLimit: 1, actorId: "fixture-admin" }); + const invite = registration.createInvite({ expiresAt: now + 86_400_000, maxUses: 3 }); + await registration.sendRegistrationCode({ email: "quota-one@example.invalid", inviteCode: invite.code }); + expect(() => registration.serviceUsage.claimResend()).toThrowError(/service_paused_quota/); + expect(resend.calls).toHaveLength(1); + + const adminId = seedAdmin(registration, now); + const adminSession = registration.issueAuthenticatedSession(adminId, "admin"); + const csrfToken = registration.issueAdminCsrfToken(adminSession.sessionToken); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration }); + await app.ready(); + const response = await app.inject({ + headers: { cookie: `dada_admin_session=${adminSession.sessionToken}`, host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }, + method: "GET", + url: "/api/v1/admin/services", + }); + expect(response.statusCode).toBe(200); + expect(response.json().services).toHaveLength(3); + expect(response.json().services.find((row: { service_id: string; period_type: string }) => row.service_id === "resend_email" && row.period_type === "daily").service_status).toBe("paused_quota"); + const health = await app.inject({ + headers: { "idempotency-key": `${randomUUID()}${randomUUID()}`, "x-csrf-token": csrfToken, cookie: `dada_admin_session=${adminSession.sessionToken}`, host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }, + method: "POST", + payload: { available: true, reason: "new_period" }, + url: "/api/v1/admin/services/resend_email/health-check", + }); + expect(health.statusCode).toBe(200); + await app.close(); + }); + + it("stops DYN004 after its quota without affecting the rest of the editor", async () => { + const registration = createRegistration(); + const userId = seedUser(registration); + const session = registration.issueAuthenticatedSession(userId, "user"); + const csrfToken = registration.issueUserCsrfToken(session.sessionToken); + const amap = new MockAmapAdapter(); + registration.serviceUsage.setHardLimit({ serviceId: "amap_web_service", periodType: "monthly", hardLimit: 1, actorId: "fixture-admin" }); + const app = await createApp({ amap, browserGate: false, networkBoundary: { allowTestPort: true }, registration }); + const headers = { cookie: `dada_session=${session.sessionToken}`, host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121", "x-csrf-token": csrfToken }; + const first = await app.inject({ headers, method: "POST", payload: { latitude: 30, longitude: 120 }, url: "/api/v1/location/reverse-geocode" }); + const second = await app.inject({ headers, method: "POST", payload: { latitude: 31, longitude: 121 }, url: "/api/v1/location/reverse-geocode" }); + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(503); + expect(amap.calls).toHaveLength(1); + await app.close(); + }); +});