Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70754ec12b |
@@ -1,194 +0,0 @@
|
|||||||
import type BetterSqlite3 from "better-sqlite3";
|
|
||||||
|
|
||||||
import type { AdminDiagnosticsResponse, AdminServicesStorageResponse } from "@dada/shared-contracts";
|
|
||||||
|
|
||||||
import type { BrowserSupportRelease } from "./browser-support.js";
|
|
||||||
import type { ManagedStorage } from "./managed-storage.js";
|
|
||||||
import type { ModelConfigurationService } from "./model-configuration.js";
|
|
||||||
|
|
||||||
type AdminService = AdminServicesStorageResponse["services"][number];
|
|
||||||
const adminServiceIds = ["resend", "amap", "ai_gateway", "worker", "api", "asset_root"] as const;
|
|
||||||
const forbiddenDiagnosticPatterns = [
|
|
||||||
/\b(?:api[_ -]?key|secret|password|credential|authorization|bearer|session[_ -]?token|cookie|prompt|email|token)\b/i,
|
|
||||||
/[A-Z]:[\\/](?:Users|Documents|ProgramData|Windows)[\\/]/i,
|
|
||||||
/\\\\[^\\\s]+\\[^\s]+/,
|
|
||||||
/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i,
|
|
||||||
/https?:\/\//i,
|
|
||||||
];
|
|
||||||
const safePauseReasons = new Set([
|
|
||||||
"asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
|
|
||||||
"contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed",
|
|
||||||
"model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown",
|
|
||||||
"worker_degraded", "worker_state_missing", "worker_stopped",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function iso(value: number | string | null | undefined) {
|
|
||||||
if (value === null || value === undefined) return null;
|
|
||||||
return typeof value === "number" ? new Date(value).toISOString() : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tableExists(database: BetterSqlite3.Database, table: string) {
|
|
||||||
return Boolean(database.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?").get(table));
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeService(
|
|
||||||
service_id: AdminService["service_id"],
|
|
||||||
status: AdminService["status"],
|
|
||||||
impact_scope: AdminService["impact_scope"],
|
|
||||||
configured: boolean,
|
|
||||||
checked_at: string | null,
|
|
||||||
pause_reason: string | null = null,
|
|
||||||
): AdminService {
|
|
||||||
return { checked_at, configured, impact_scope, pause_reason, service_id, status };
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeReason(value: unknown) {
|
|
||||||
return typeof value === "string" && safePauseReasons.has(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceUsage(database: BetterSqlite3.Database, serviceName: string) {
|
|
||||||
if (!tableExists(database, "external_service_usage")) return undefined;
|
|
||||||
const columns = new Set((database.prepare("PRAGMA table_info(external_service_usage)").all() as Array<{ name: string }>).map((column) => column.name));
|
|
||||||
const serviceColumn = columns.has("service_name") ? "service_name" : columns.has("service_id") ? "service_id" : undefined;
|
|
||||||
if (!serviceColumn || !columns.has("service_status")) return undefined;
|
|
||||||
const row = database.prepare(`SELECT service_status, ${columns.has("checked_at") ? "checked_at" : "NULL AS checked_at"}, ${columns.has("pause_reason") ? "pause_reason" : "NULL AS pause_reason"} FROM external_service_usage WHERE ${serviceColumn} = ? ORDER BY rowid DESC LIMIT 1`).get(serviceName) as { service_status: string; checked_at: number | string | null; pause_reason: string | null } | undefined;
|
|
||||||
if (!row) return undefined;
|
|
||||||
const status = new Set<AdminService["status"]>(["active", "paused_quota", "paused_provider", "disabled"]).has(row.service_status as AdminService["status"])
|
|
||||||
? row.service_status as AdminService["status"]
|
|
||||||
: "degraded";
|
|
||||||
return { status, checked_at: iso(row.checked_at), pause_reason: safeReason(row.pause_reason) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function workerStatus(database: BetterSqlite3.Database) {
|
|
||||||
if (!tableExists(database, "worker_runtime_state")) return { status: "unavailable" as const, checked_at: null, pause_reason: "worker_state_missing" };
|
|
||||||
const row = database.prepare("SELECT status, reason, updated_at FROM worker_runtime_state WHERE singleton = 1").get() as { status: string; reason: string | null; updated_at: number | string | null } | undefined;
|
|
||||||
if (!row) return { status: "unavailable" as const, checked_at: null, pause_reason: "worker_state_missing" };
|
|
||||||
return {
|
|
||||||
status: row.status === "ready" ? "active" as const : row.status === "degraded" ? "degraded" as const : "unavailable" as const,
|
|
||||||
checked_at: iso(row.updated_at),
|
|
||||||
pause_reason: safeReason(row.reason),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAdminServicesStorageProvider(input: {
|
|
||||||
database: BetterSqlite3.Database;
|
|
||||||
models?: ModelConfigurationService;
|
|
||||||
storage?: ManagedStorage;
|
|
||||||
assetRoot?: Pick<AdminService, "configured" | "status" | "checked_at" | "pause_reason">;
|
|
||||||
clock?: () => number;
|
|
||||||
}): () => AdminServicesStorageResponse {
|
|
||||||
const clock = input.clock ?? Date.now;
|
|
||||||
return () => {
|
|
||||||
const generatedAt = new Date(clock()).toISOString();
|
|
||||||
const resend = serviceUsage(input.database, "resend");
|
|
||||||
const amap = serviceUsage(input.database, "amap");
|
|
||||||
const worker = workerStatus(input.database);
|
|
||||||
const modelRuntime = input.models?.read().models ?? [];
|
|
||||||
const unavailableModels = modelRuntime.filter((model) => !model.runtime_availability.available_for_new_jobs);
|
|
||||||
const gatewayReason = unavailableModels[0]?.runtime_availability.reason ?? null;
|
|
||||||
const storageState = input.storage?.getState();
|
|
||||||
const cleanupPendingCount = tableExists(input.database, "file_cleanup_queue")
|
|
||||||
? (input.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE status IN ('pending', 'failed')").get() as { count: number }).count
|
|
||||||
: 0;
|
|
||||||
const services: AdminService[] = [
|
|
||||||
safeService("resend", resend?.status ?? "unavailable", "authentication", Boolean(resend), resend?.checked_at ?? null, resend?.pause_reason ?? "service_state_missing"),
|
|
||||||
safeService("amap", amap?.status ?? "unavailable", "location", Boolean(amap), amap?.checked_at ?? null, amap?.pause_reason ?? "service_state_missing"),
|
|
||||||
safeService("ai_gateway", unavailableModels.length > 0 ? "degraded" : modelRuntime.length > 0 ? "active" : "unavailable", "generation", modelRuntime.length > 0, generatedAt, safeReason(gatewayReason)),
|
|
||||||
safeService("worker", worker.status, "generation", worker.status !== "unavailable", worker.checked_at, worker.pause_reason),
|
|
||||||
safeService("api", "active", "api", true, generatedAt),
|
|
||||||
safeService(
|
|
||||||
"asset_root",
|
|
||||||
input.assetRoot?.status ?? "unavailable",
|
|
||||||
"storage",
|
|
||||||
input.assetRoot?.configured ?? false,
|
|
||||||
input.assetRoot?.checked_at ?? null,
|
|
||||||
input.assetRoot?.pause_reason ?? "asset_root_state_missing",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
return assertSafeAdminServicesStorage({
|
|
||||||
generated_at: generatedAt,
|
|
||||||
services,
|
|
||||||
storage: {
|
|
||||||
capacity_notice_level: storageState?.capacity_notice_level ?? "normal",
|
|
||||||
cleanup_pending_count: cleanupPendingCount,
|
|
||||||
data_root_ref: "configured_local_data_root",
|
|
||||||
hard_limit_bytes: storageState?.hard_limit_bytes ?? 5_368_709_120,
|
|
||||||
last_measured_at: storageState?.measured_at ?? null,
|
|
||||||
managed_content_bytes: storageState?.managed_content_bytes ?? 0,
|
|
||||||
remeasurement_required: storageState?.storage_status === "unavailable",
|
|
||||||
status: storageState?.storage_status ?? "unavailable",
|
|
||||||
storage_backend: "local_filesystem",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnosticText(input: AdminServicesStorageResponse, system: AdminDiagnosticsResponse["system"]) {
|
|
||||||
const lines = [
|
|
||||||
"Dada P0-A diagnostics",
|
|
||||||
`app_version=${system.app_version}`,
|
|
||||||
`api_status=${system.api_status}`,
|
|
||||||
`worker_status=${system.worker_status}`,
|
|
||||||
`storage_status=${input.storage.status}`,
|
|
||||||
`capacity_notice_level=${input.storage.capacity_notice_level}`,
|
|
||||||
`managed_content_bytes=${input.storage.managed_content_bytes}`,
|
|
||||||
`hard_limit_bytes=${input.storage.hard_limit_bytes}`,
|
|
||||||
`cleanup_pending_count=${input.storage.cleanup_pending_count}`,
|
|
||||||
];
|
|
||||||
for (const service of input.services) lines.push(`service.${service.service_id}=${service.status}`);
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAdminDiagnosticsProvider(input: {
|
|
||||||
servicesStorage: () => AdminServicesStorageResponse;
|
|
||||||
browserSupportRelease?: BrowserSupportRelease;
|
|
||||||
appVersion?: string;
|
|
||||||
clock?: () => number;
|
|
||||||
}): () => AdminDiagnosticsResponse {
|
|
||||||
const clock = input.clock ?? Date.now;
|
|
||||||
return () => {
|
|
||||||
const services = input.servicesStorage();
|
|
||||||
const system: AdminDiagnosticsResponse["system"] = {
|
|
||||||
api_status: "ready",
|
|
||||||
app_version: input.appVersion ?? input.browserSupportRelease?.appVersion ?? "0.0.0",
|
|
||||||
browser_support: (input.browserSupportRelease?.browsers ?? []).map((browser) => ({
|
|
||||||
brand: browser.brand,
|
|
||||||
major: Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10),
|
|
||||||
})).filter((browser) => Number.isSafeInteger(browser.major) && browser.major > 0),
|
|
||||||
worker_status: services.services.find((service) => service.service_id === "worker")?.status === "active"
|
|
||||||
? "ready"
|
|
||||||
: services.services.find((service) => service.service_id === "worker")?.status === "unavailable"
|
|
||||||
? "unavailable"
|
|
||||||
: "degraded",
|
|
||||||
};
|
|
||||||
return assertSafeAdminDiagnostics({
|
|
||||||
generated_at: new Date(clock()).toISOString(),
|
|
||||||
diagnostic_text: diagnosticText(services, system),
|
|
||||||
services,
|
|
||||||
system,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertSafeAdminServicesStorage(input: AdminServicesStorageResponse) {
|
|
||||||
const ids = input.services.map((service) => service.service_id);
|
|
||||||
if (ids.length !== adminServiceIds.length || new Set(ids).size !== adminServiceIds.length
|
|
||||||
|| adminServiceIds.some((serviceId) => !ids.includes(serviceId))) {
|
|
||||||
throw new Error("admin_service_state_incomplete");
|
|
||||||
}
|
|
||||||
if (input.services.some((service) => service.pause_reason !== null && !safePauseReasons.has(service.pause_reason))) {
|
|
||||||
throw new Error("admin_services_redaction_failed");
|
|
||||||
}
|
|
||||||
if (forbiddenDiagnosticPatterns.some((pattern) => pattern.test(JSON.stringify(input)))) {
|
|
||||||
throw new Error("admin_services_redaction_failed");
|
|
||||||
}
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertSafeAdminDiagnostics(input: AdminDiagnosticsResponse) {
|
|
||||||
assertSafeAdminServicesStorage(input.services);
|
|
||||||
if (forbiddenDiagnosticPatterns.some((pattern) => pattern.test(input.diagnostic_text))) {
|
|
||||||
throw new Error("admin_diagnostics_redaction_failed");
|
|
||||||
}
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
+147
-39
@@ -10,9 +10,16 @@ import {
|
|||||||
AccountProfileUpdateResponseSchema,
|
AccountProfileUpdateResponseSchema,
|
||||||
AccountSettingsResponseSchema,
|
AccountSettingsResponseSchema,
|
||||||
AdminAuthenticatedUserSchema,
|
AdminAuthenticatedUserSchema,
|
||||||
AdminDiagnosticsResponseSchema,
|
|
||||||
AdminOverviewResponseSchema,
|
AdminOverviewResponseSchema,
|
||||||
AdminServicesStorageResponseSchema,
|
AdminServicesResponseSchema,
|
||||||
|
AdminServiceHealthCheckRequestSchema,
|
||||||
|
AdminServiceLimitRequestSchema,
|
||||||
|
AdminServiceParamsSchema,
|
||||||
|
AdminServiceRecoveryRequestSchema,
|
||||||
|
ExternalServiceIdSchema,
|
||||||
|
ExternalServicePeriodTypeSchema,
|
||||||
|
ExternalServiceStatusSchema,
|
||||||
|
ExternalServiceUsageSchema,
|
||||||
AdminCreditParamsSchema,
|
AdminCreditParamsSchema,
|
||||||
AdminLoginCompleteRequestSchema,
|
AdminLoginCompleteRequestSchema,
|
||||||
AdminLoginCompleteResponseSchema,
|
AdminLoginCompleteResponseSchema,
|
||||||
@@ -114,8 +121,6 @@ import {
|
|||||||
type AdminLoginCompleteRequest,
|
type AdminLoginCompleteRequest,
|
||||||
type AdminLoginSendRequest,
|
type AdminLoginSendRequest,
|
||||||
type AdminOverviewResponse,
|
type AdminOverviewResponse,
|
||||||
type AdminDiagnosticsResponse,
|
|
||||||
type AdminServicesStorageResponse,
|
|
||||||
type AccountDeletionCompleteRequest,
|
type AccountDeletionCompleteRequest,
|
||||||
type AccountProfileUpdateRequest,
|
type AccountProfileUpdateRequest,
|
||||||
type AdminCreditParams,
|
type AdminCreditParams,
|
||||||
@@ -182,9 +187,9 @@ import {
|
|||||||
import type { RegistrationService } from "./registration.js";
|
import type { RegistrationService } from "./registration.js";
|
||||||
import type { RecentAssetService } from "./recent-assets.js";
|
import type { RecentAssetService } from "./recent-assets.js";
|
||||||
import type { AmapAdapter } from "./amap-adapter.js";
|
import type { AmapAdapter } from "./amap-adapter.js";
|
||||||
|
import { ExternalServiceUsageError, type ExternalServiceUsage } from "./external-service-usage.js";
|
||||||
import { ModelConfigurationError } from "./model-configuration.js";
|
import { ModelConfigurationError } from "./model-configuration.js";
|
||||||
import type { ModelConfigurationService } from "./model-configuration.js";
|
import type { ModelConfigurationService } from "./model-configuration.js";
|
||||||
import { assertSafeAdminDiagnostics, assertSafeAdminServicesStorage } from "./admin-state.js";
|
|
||||||
|
|
||||||
const defaultBootstrap: BootstrapResponse = {
|
const defaultBootstrap: BootstrapResponse = {
|
||||||
app_version: "0.0.0",
|
app_version: "0.0.0",
|
||||||
@@ -199,9 +204,7 @@ const defaultBootstrap: BootstrapResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface CreateAppOptions {
|
export interface CreateAppOptions {
|
||||||
adminDiagnostics?: () => AdminDiagnosticsResponse | Promise<AdminDiagnosticsResponse>;
|
|
||||||
adminOverview?: () => AdminOverviewResponse | Promise<AdminOverviewResponse>;
|
adminOverview?: () => AdminOverviewResponse | Promise<AdminOverviewResponse>;
|
||||||
adminServicesStorage?: () => AdminServicesStorageResponse | Promise<AdminServicesStorageResponse>;
|
|
||||||
amap?: AmapAdapter;
|
amap?: AmapAdapter;
|
||||||
assetReleases?: AssetReleaseReader;
|
assetReleases?: AssetReleaseReader;
|
||||||
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
||||||
@@ -229,6 +232,7 @@ export interface CreateAppOptions {
|
|||||||
resourceId: string;
|
resourceId: string;
|
||||||
}) => boolean | Promise<boolean>;
|
}) => boolean | Promise<boolean>;
|
||||||
registration?: RegistrationService;
|
registration?: RegistrationService;
|
||||||
|
serviceUsage?: ExternalServiceUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate");
|
const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate");
|
||||||
@@ -699,8 +703,15 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
AdminLoginCompleteResponseSchema,
|
AdminLoginCompleteResponseSchema,
|
||||||
AdminSessionResponseSchema,
|
AdminSessionResponseSchema,
|
||||||
AdminOverviewResponseSchema,
|
AdminOverviewResponseSchema,
|
||||||
AdminServicesStorageResponseSchema,
|
AdminServicesResponseSchema,
|
||||||
AdminDiagnosticsResponseSchema,
|
AdminServiceHealthCheckRequestSchema,
|
||||||
|
AdminServiceLimitRequestSchema,
|
||||||
|
AdminServiceParamsSchema,
|
||||||
|
AdminServiceRecoveryRequestSchema,
|
||||||
|
ExternalServiceIdSchema,
|
||||||
|
ExternalServicePeriodTypeSchema,
|
||||||
|
ExternalServiceStatusSchema,
|
||||||
|
ExternalServiceUsageSchema,
|
||||||
CreditSummarySchema,
|
CreditSummarySchema,
|
||||||
CreditEntryTypeSchema,
|
CreditEntryTypeSchema,
|
||||||
CreditEntryStatusSchema,
|
CreditEntryStatusSchema,
|
||||||
@@ -1073,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 }));
|
if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||||
try {
|
try {
|
||||||
options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
|
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);
|
const result = await options.amap.reverseGeocode(request.body as ReverseGeocodeRequest);
|
||||||
return { formatted_value: result.formattedValue, service_mode: result.serviceMode, status: "resolved" as const };
|
return { formatted_value: result.formattedValue, service_mode: result.serviceMode, status: "resolved" as const };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, 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);
|
return reply.code(503).send(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1246,55 +1263,146 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/api/v1/admin/services-storage",
|
"/api/v1/admin/services",
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
operationId: "getAdminServicesStorage",
|
operationId: "getAdminServices",
|
||||||
response: {
|
response: { 200: Type.Ref(AdminServicesResponseSchema), 401: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) },
|
||||||
200: Type.Ref(AdminServicesStorageResponseSchema),
|
tags: ["Admin Services"],
|
||||||
401: Type.Ref(ErrorEnvelopeSchema),
|
|
||||||
503: Type.Null(),
|
|
||||||
},
|
|
||||||
tags: ["Admin Operations"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
if (!options.registration) return reply.code(503).send(null);
|
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 token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
|
||||||
const session = token ? options.registration.readAdminSession(token) : undefined;
|
const session = token ? registration.readAdminSession(token) : undefined;
|
||||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||||
if (!options.adminServicesStorage) return reply.code(503).send(null);
|
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 {
|
try {
|
||||||
return assertSafeAdminServicesStorage(await options.adminServicesStorage());
|
const headers = request.headers as { "x-csrf-token": string };
|
||||||
} catch {
|
registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
|
||||||
return reply.code(503).send(null);
|
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.get(
|
app.patch(
|
||||||
"/api/v1/admin/diagnostics",
|
"/api/v1/admin/services/:service_id/limits",
|
||||||
{
|
{
|
||||||
|
attachValidation: true,
|
||||||
schema: {
|
schema: {
|
||||||
operationId: "getAdminDiagnostics",
|
params: Type.Ref(AdminServiceParamsSchema),
|
||||||
response: {
|
body: Type.Ref(AdminServiceLimitRequestSchema),
|
||||||
200: Type.Ref(AdminDiagnosticsResponseSchema),
|
headers: Type.Ref(ModelConfigUpdateHeadersSchema),
|
||||||
401: Type.Ref(ErrorEnvelopeSchema),
|
operationId: "updateAdminServiceHardLimit",
|
||||||
503: Type.Null(),
|
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"],
|
||||||
tags: ["Admin Operations"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
if (!options.registration) return reply.code(503).send(null);
|
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);
|
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
|
||||||
const session = token ? options.registration.readAdminSession(token) : undefined;
|
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
|
||||||
if (!options.adminDiagnostics) return reply.code(503).send(null);
|
|
||||||
try {
|
try {
|
||||||
return assertSafeAdminDiagnostics(await options.adminDiagnostics());
|
const headers = request.headers as { "x-csrf-token": string };
|
||||||
} catch {
|
const admin = registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
|
||||||
return reply.code(503).send(null);
|
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 }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<ExternalServiceId, Partial<Record<ExternalServicePeriodType, number>>> = {
|
||||||
|
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<T>(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<string, unknown> | null;
|
||||||
|
beforeSummary: Record<string, unknown> | 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";
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ import { StructuredJsonlLogger } from "./structured-log.js";
|
|||||||
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
||||||
import { ModelConfigurationService } from "./model-configuration.js";
|
import { ModelConfigurationService } from "./model-configuration.js";
|
||||||
import { MockAmapAdapter } from "./amap-adapter.js";
|
import { MockAmapAdapter } from "./amap-adapter.js";
|
||||||
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
|
||||||
|
|
||||||
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
||||||
let registration: RegistrationService | undefined;
|
let registration: RegistrationService | undefined;
|
||||||
@@ -71,22 +70,7 @@ if (credentialChannelEnabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
|
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
|
||||||
const adminServicesStorage = registration
|
|
||||||
? createAdminServicesStorageProvider({
|
|
||||||
database: registration.database,
|
|
||||||
...(models ? { models } : {}),
|
|
||||||
...(storage ? { storage } : {}),
|
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
const adminDiagnostics = adminServicesStorage
|
|
||||||
? createAdminDiagnosticsProvider({
|
|
||||||
...(browserSupportRelease ? { browserSupportRelease, appVersion: browserSupportRelease.appVersion } : {}),
|
|
||||||
servicesStorage: adminServicesStorage,
|
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
const app = await createApp({
|
const app = await createApp({
|
||||||
...(adminServicesStorage ? { adminServicesStorage } : {}),
|
|
||||||
...(adminDiagnostics ? { adminDiagnostics } : {}),
|
|
||||||
amap: new MockAmapAdapter(),
|
amap: new MockAmapAdapter(),
|
||||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||||
...(credits ? { credits } : {}),
|
...(credits ? { credits } : {}),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
serializeAuditSummary,
|
serializeAuditSummary,
|
||||||
} from "./audit-policy.js";
|
} from "./audit-policy.js";
|
||||||
import type { ResendAdapter } from "./resend-adapter.js";
|
import type { ResendAdapter } from "./resend-adapter.js";
|
||||||
|
import { ExternalServiceUsage } from "./external-service-usage.js";
|
||||||
import {
|
import {
|
||||||
RegistrationError,
|
RegistrationError,
|
||||||
type RegistrationErrorReason,
|
type RegistrationErrorReason,
|
||||||
@@ -226,6 +227,7 @@ function constantTimeTextEqual(left: string, right: string) {
|
|||||||
|
|
||||||
export class RegistrationService {
|
export class RegistrationService {
|
||||||
readonly database: BetterSqlite3.Database;
|
readonly database: BetterSqlite3.Database;
|
||||||
|
readonly serviceUsage: ExternalServiceUsage;
|
||||||
readonly options: Required<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
|
readonly options: Required<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
|
||||||
private adminAllowlistHashes = new Set<string>();
|
private adminAllowlistHashes = new Set<string>();
|
||||||
private privacyPurgeActive = false;
|
private privacyPurgeActive = false;
|
||||||
@@ -254,6 +256,7 @@ export class RegistrationService {
|
|||||||
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
|
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
|
||||||
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
|
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
|
||||||
this.migrate();
|
this.migrate();
|
||||||
|
this.serviceUsage = new ExternalServiceUsage({ database: this.database, clock: this.options.clock });
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
@@ -299,6 +302,7 @@ export class RegistrationService {
|
|||||||
if (existing) throw new RegistrationError("AUTH_ENTRY_REJECTED", "registration_login_required");
|
if (existing) throw new RegistrationError("AUTH_ENTRY_REJECTED", "registration_login_required");
|
||||||
this.assertChallengeSendAllowed(email, "register", "registration", now);
|
this.assertChallengeSendAllowed(email, "register", "registration", now);
|
||||||
this.recordRateSend(email, "registration", now);
|
this.recordRateSend(email, "registration", now);
|
||||||
|
this.serviceUsage.claimResendWithinTransaction(now);
|
||||||
|
|
||||||
this.database.prepare(`
|
this.database.prepare(`
|
||||||
INSERT INTO email_challenges (
|
INSERT INTO email_challenges (
|
||||||
@@ -328,6 +332,7 @@ export class RegistrationService {
|
|||||||
try {
|
try {
|
||||||
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "register" });
|
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "register" });
|
||||||
} catch {
|
} catch {
|
||||||
|
this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now });
|
||||||
this.runImmediate("registration_send_compensation", () => {
|
this.runImmediate("registration_send_compensation", () => {
|
||||||
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
|
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
|
||||||
return { outcome: "committed", value: undefined };
|
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");
|
if (user.role !== "user") throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_admin_required");
|
||||||
this.assertChallengeSendAllowed(email, "login", clientKey, now);
|
this.assertChallengeSendAllowed(email, "login", clientKey, now);
|
||||||
this.recordRateSend(email, clientKey, now);
|
this.recordRateSend(email, clientKey, now);
|
||||||
|
this.serviceUsage.claimResendWithinTransaction(now);
|
||||||
this.database.prepare(`
|
this.database.prepare(`
|
||||||
INSERT INTO email_challenges (
|
INSERT INTO email_challenges (
|
||||||
challenge_id, email, invite_id, code_hmac, purpose, expires_at,
|
challenge_id, email, invite_id, code_hmac, purpose, expires_at,
|
||||||
@@ -382,6 +388,7 @@ export class RegistrationService {
|
|||||||
try {
|
try {
|
||||||
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "login" });
|
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "login" });
|
||||||
} catch {
|
} catch {
|
||||||
|
this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now });
|
||||||
this.runImmediate("registration_send_compensation", () => {
|
this.runImmediate("registration_send_compensation", () => {
|
||||||
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
|
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
|
||||||
return { outcome: "committed", value: undefined };
|
return { outcome: "committed", value: undefined };
|
||||||
@@ -768,6 +775,7 @@ export class RegistrationService {
|
|||||||
}
|
}
|
||||||
this.assertChallengeSendAllowed(email, "admin_login", clientKey, now);
|
this.assertChallengeSendAllowed(email, "admin_login", clientKey, now);
|
||||||
this.recordRateSend(email, clientKey, now);
|
this.recordRateSend(email, clientKey, now);
|
||||||
|
this.serviceUsage.claimResendWithinTransaction(now);
|
||||||
this.database.prepare(`
|
this.database.prepare(`
|
||||||
INSERT INTO email_challenges (
|
INSERT INTO email_challenges (
|
||||||
challenge_id, email, invite_id, code_hmac, purpose, expires_at,
|
challenge_id, email, invite_id, code_hmac, purpose, expires_at,
|
||||||
@@ -795,6 +803,7 @@ export class RegistrationService {
|
|||||||
try {
|
try {
|
||||||
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "admin_login" });
|
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "admin_login" });
|
||||||
} catch {
|
} catch {
|
||||||
|
this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now });
|
||||||
this.runImmediate("registration_send_compensation", () => {
|
this.runImmediate("registration_send_compensation", () => {
|
||||||
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
|
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
|
||||||
this.recordAdminLoginRejection("service_unavailable", now);
|
this.recordAdminLoginRejection("service_unavailable", now);
|
||||||
@@ -1097,6 +1106,7 @@ export class RegistrationService {
|
|||||||
now + resendDelayMilliseconds,
|
now + resendDelayMilliseconds,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
this.serviceUsage.claimResendWithinTransaction(now);
|
||||||
return {
|
return {
|
||||||
outcome: "committed",
|
outcome: "committed",
|
||||||
value: {
|
value: {
|
||||||
@@ -1116,6 +1126,7 @@ export class RegistrationService {
|
|||||||
purpose: "account_delete",
|
purpose: "account_delete",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now });
|
||||||
this.runImmediate("registration_send_compensation", () => {
|
this.runImmediate("registration_send_compensation", () => {
|
||||||
this.database.prepare("DELETE FROM account_deletion_challenges WHERE deletion_id = ? AND consumed_at IS NULL").run(deletionId);
|
this.database.prepare("DELETE FROM account_deletion_challenges WHERE deletion_id = ? AND consumed_at IS NULL").run(deletionId);
|
||||||
return { outcome: "committed", value: undefined };
|
return { outcome: "committed", value: undefined };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import "./admin-models.css";
|
import "./admin-models.css";
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ export function AdminModelsPage() {
|
|||||||
const priorityRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
const priorityRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
const defaultRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
const defaultRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
async function load() {
|
||||||
setLoadingFailed(false);
|
setLoadingFailed(false);
|
||||||
setConflicted(false);
|
setConflicted(false);
|
||||||
try {
|
try {
|
||||||
@@ -80,16 +80,9 @@ export function AdminModelsPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
setLoadingFailed(true);
|
setLoadingFailed(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
useEffect(() => { void load(); }, [load]);
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof EventSource === "undefined") return undefined;
|
|
||||||
const source = new EventSource("/api/v1/events");
|
|
||||||
source.onmessage = () => { void load(); };
|
|
||||||
return () => source.close();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const validation = useMemo(() => {
|
const validation = useMemo(() => {
|
||||||
if (!draft) return { valid: false, message: "" };
|
if (!draft) return { valid: false, message: "" };
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
.admin-services-storage { max-width: 1180px; }
|
|
||||||
.admin-services-heading { align-items: end; }
|
|
||||||
.admin-services-heading-actions { align-items: center; display: flex; gap: 16px; }
|
|
||||||
.admin-services-heading-actions button, .admin-diagnostics-section button { background: #111827; border: 0; color: #fff; cursor: pointer; font: inherit; padding: 10px 14px; }
|
|
||||||
.admin-health-section { border-top: 1px solid #d9dde5; margin-top: 26px; padding-top: 22px; }
|
|
||||||
.admin-health-section > header { align-items: center; display: flex; justify-content: space-between; margin-bottom: 18px; }
|
|
||||||
.admin-health-section h3 { margin: 4px 0 0; }
|
|
||||||
.admin-health-section header p { color: #7b8493; font-size: 11px; letter-spacing: .12em; margin: 0; }
|
|
||||||
.admin-safe-note { color: #667085; font-size: 13px; }
|
|
||||||
.admin-service-grid { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
||||||
.admin-service-card { background: #fff; border: 1px solid #e1e5ea; min-height: 160px; padding: 18px; }
|
|
||||||
.admin-service-card.is-degraded, .admin-service-card.is-paused_quota, .admin-service-card.is-paused_provider, .admin-service-card.is-unavailable { border-color: #e5b6b6; }
|
|
||||||
.admin-service-card-heading { align-items: center; display: flex; justify-content: space-between; }
|
|
||||||
.admin-service-card-heading span, .admin-storage-state { color: #147a50; font-size: 13px; }
|
|
||||||
.admin-service-card.is-degraded .admin-service-card-heading span, .admin-service-card.is-paused_quota .admin-service-card-heading span, .admin-service-card.is-paused_provider .admin-service-card-heading span, .admin-service-card.is-unavailable .admin-service-card-heading span { color: #b42318; }
|
|
||||||
.admin-service-card dl, .admin-storage-details { display: grid; gap: 10px; margin: 18px 0 0; }
|
|
||||||
.admin-service-card dl div, .admin-storage-details div { align-items: baseline; display: flex; justify-content: space-between; }
|
|
||||||
.admin-service-card dt, .admin-storage-details dt { color: #667085; font-size: 12px; }
|
|
||||||
.admin-service-card dd, .admin-storage-details dd { margin: 0; text-align: right; }
|
|
||||||
.admin-storage-state.is-full, .admin-storage-state.is-unavailable { color: #b42318; }
|
|
||||||
.admin-storage-metrics { display: grid; gap: 16px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
||||||
.admin-storage-metrics div { background: #f7f8fa; padding: 14px 16px; }
|
|
||||||
.admin-storage-metrics span { color: #667085; display: block; font-size: 12px; }
|
|
||||||
.admin-storage-metrics strong { display: block; font-size: 20px; margin-top: 6px; }
|
|
||||||
.admin-storage-progress { background: #e5e7eb; height: 8px; margin-top: 18px; overflow: hidden; }
|
|
||||||
.admin-storage-progress span { background: #147a50; display: block; height: 100%; }
|
|
||||||
.admin-storage-description { color: #667085; font-size: 13px; line-height: 1.7; max-width: 780px; }
|
|
||||||
.admin-storage-description code { color: #344054; }
|
|
||||||
.admin-diagnostics-section > header button:disabled { background: #98a2b3; cursor: not-allowed; }
|
|
||||||
.admin-diagnostics-section > p { color: #667085; font-size: 13px; }
|
|
||||||
.admin-diagnostics-section pre { background: #111827; color: #d1fadf; font: 12px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; margin: 16px 0 0; max-height: 280px; overflow: auto; padding: 16px; white-space: pre-wrap; }
|
|
||||||
.admin-services-loading { display: grid; gap: 12px; grid-template-columns: repeat(3, 1fr); }
|
|
||||||
.admin-services-loading span, .admin-diagnostics-placeholder { background: #eef1f4; display: block; height: 160px; }
|
|
||||||
.admin-services-failure { align-items: center; background: #fff4f2; color: #b42318; display: flex; gap: 16px; justify-content: space-between; padding: 14px 16px; }
|
|
||||||
.admin-services-failure button { background: transparent; border: 1px solid #b42318; color: #b42318; cursor: pointer; padding: 6px 12px; }
|
|
||||||
@media (max-width: 900px) { .admin-service-grid, .admin-storage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
||||||
@media (max-width: 620px) { .admin-service-grid, .admin-storage-metrics { grid-template-columns: 1fr; } .admin-services-heading-actions { align-items: flex-end; flex-direction: column; gap: 8px; } }
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import type { AdminDiagnosticsResponse, AdminServicesStorageResponse } from "@dada/shared-contracts";
|
|
||||||
|
|
||||||
import "./admin-services-storage.css";
|
|
||||||
|
|
||||||
const serviceLabels: Record<AdminServicesStorageResponse["services"][number]["service_id"], string> = {
|
|
||||||
ai_gateway: "AI 网关",
|
|
||||||
amap: "高德",
|
|
||||||
api: "API",
|
|
||||||
asset_root: "素材根",
|
|
||||||
resend: "Resend",
|
|
||||||
worker: "Worker",
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabels: Record<AdminServicesStorageResponse["services"][number]["status"], string> = {
|
|
||||||
active: "正常",
|
|
||||||
degraded: "有异常",
|
|
||||||
disabled: "已停用",
|
|
||||||
paused_provider: "供应商暂停",
|
|
||||||
paused_quota: "额度暂停",
|
|
||||||
unavailable: "不可用",
|
|
||||||
};
|
|
||||||
|
|
||||||
const impactLabels: Record<AdminServicesStorageResponse["services"][number]["impact_scope"], string> = {
|
|
||||||
account: "账户模型",
|
|
||||||
api: "后台接口",
|
|
||||||
authentication: "认证",
|
|
||||||
generation: "生成",
|
|
||||||
location: "定位",
|
|
||||||
model: "单模型",
|
|
||||||
none: "无",
|
|
||||||
storage: "存储",
|
|
||||||
unknown: "未知范围",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatTime(value: string | null) {
|
|
||||||
if (!value) return "未记录";
|
|
||||||
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getJson<T>(url: string) {
|
|
||||||
const response = await fetch(url, { credentials: "same-origin" });
|
|
||||||
if (response.status === 401) window.dispatchEvent(new Event("dada:session-invalid"));
|
|
||||||
if (!response.ok) throw new Error("admin_state_unavailable");
|
|
||||||
return await response.json() as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminServicesStoragePage() {
|
|
||||||
const [state, setState] = useState<AdminServicesStorageResponse>();
|
|
||||||
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsResponse>();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setFailed(false);
|
|
||||||
try {
|
|
||||||
const [nextState, nextDiagnostics] = await Promise.all([
|
|
||||||
getJson<AdminServicesStorageResponse>("/api/v1/admin/services-storage"),
|
|
||||||
getJson<AdminDiagnosticsResponse>("/api/v1/admin/diagnostics"),
|
|
||||||
]);
|
|
||||||
setState(nextState);
|
|
||||||
setDiagnostics(nextDiagnostics);
|
|
||||||
} catch {
|
|
||||||
setFailed(true);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { void load(); }, [load]);
|
|
||||||
|
|
||||||
async function copyDiagnostics() {
|
|
||||||
if (!diagnostics) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(diagnostics.diagnostic_text);
|
|
||||||
setCopied(true);
|
|
||||||
window.setTimeout(() => setCopied(false), 1800);
|
|
||||||
} catch {
|
|
||||||
setCopied(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="admin-services-storage" id="admin-main">
|
|
||||||
<header className="admin-page-heading admin-services-heading">
|
|
||||||
<div><p>OPERATIONS / HEALTH</p><h2>服务与存储</h2></div>
|
|
||||||
<div className="admin-services-heading-actions">
|
|
||||||
{state ? <time dateTime={state.generated_at}>更新于 {formatTime(state.generated_at)}</time> : null}
|
|
||||||
<button aria-label="重新读取服务与存储状态" onClick={() => void load()} type="button">重新读取</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{loading && !state ? <div aria-label="服务与存储状态加载中" className="admin-services-loading"><span /><span /><span /><span /></div> : null}
|
|
||||||
{failed ? <div className="admin-services-failure" role="alert"><span>状态暂时无法读取{state ? `,保留 ${formatTime(state.generated_at)} 的结果` : ""}。</span><button onClick={() => void load()} type="button">重试</button></div> : null}
|
|
||||||
{state ? (
|
|
||||||
<>
|
|
||||||
<section aria-labelledby="admin-services-list-heading" className="admin-health-section">
|
|
||||||
<header><div><p>SERVICE STATUS</p><h3 id="admin-services-list-heading">外部服务与本机组件</h3></div><span className="admin-safe-note">仅显示脱敏状态</span></header>
|
|
||||||
<div className="admin-service-grid">
|
|
||||||
{state.services.map((service) => (
|
|
||||||
<article className={`admin-service-card is-${service.status}`} key={service.service_id}>
|
|
||||||
<div className="admin-service-card-heading"><strong>{serviceLabels[service.service_id]}</strong><span>{statusLabels[service.status]}</span></div>
|
|
||||||
<dl>
|
|
||||||
<div><dt>配置状态</dt><dd>{service.configured ? "已配置" : "未配置"}</dd></div>
|
|
||||||
<div><dt>影响范围</dt><dd>{impactLabels[service.impact_scope]}</dd></div>
|
|
||||||
<div><dt>最近检查</dt><dd>{formatTime(service.checked_at)}</dd></div>
|
|
||||||
{service.pause_reason ? <div><dt>安全原因</dt><dd>{service.pause_reason}</dd></div> : null}
|
|
||||||
</dl>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section aria-labelledby="admin-storage-heading" className="admin-health-section">
|
|
||||||
<header><div><p>LOCAL DATA ROOT</p><h3 id="admin-storage-heading">本机内容容量</h3></div><span className={`admin-storage-state is-${state.storage.status}`}>{state.storage.status === "active" ? "可写" : state.storage.status === "full" ? "已满" : "不可用"}</span></header>
|
|
||||||
<div className="admin-storage-metrics">
|
|
||||||
<div><span>已用内容</span><strong>{(state.storage.managed_content_bytes / 1024 / 1024 / 1024).toFixed(2)} GB</strong></div>
|
|
||||||
<div><span>固定上限</span><strong>{(state.storage.hard_limit_bytes / 1024 / 1024 / 1024).toFixed(2)} GB</strong></div>
|
|
||||||
<div><span>容量提醒</span><strong>{state.storage.capacity_notice_level}</strong></div>
|
|
||||||
<div><span>清理队列</span><strong>{state.storage.cleanup_pending_count}</strong></div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-storage-progress" aria-label={`本机内容容量 ${(state.storage.managed_content_bytes / state.storage.hard_limit_bytes * 100).toFixed(1)}%`}><span style={{ width: `${Math.min(100, state.storage.managed_content_bytes / state.storage.hard_limit_bytes * 100)}%` }} /></div>
|
|
||||||
<p className="admin-storage-description">测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。当前 Windows 用户的 Dada 本机数据目录引用:<code>{state.storage.data_root_ref}</code>。只读规范素材库不计入 5 GB 内容额度。</p>
|
|
||||||
<dl className="admin-storage-details"><div><dt>最后计量</dt><dd>{formatTime(state.storage.last_measured_at)}</dd></div><div><dt>重新计量</dt><dd>{state.storage.remeasurement_required ? "需要完成" : "无需等待"}</dd></div></dl>
|
|
||||||
</section>
|
|
||||||
<section aria-labelledby="admin-diagnostics-heading" className="admin-health-section admin-diagnostics-section">
|
|
||||||
<header><div><p>DIAGNOSTICS</p><h3 id="admin-diagnostics-heading">脱敏诊断</h3></div><button disabled={!diagnostics} onClick={() => void copyDiagnostics()} type="button">{copied ? "已复制" : "复制诊断"}</button></header>
|
|
||||||
<p>诊断内容只包含固定版本、组件状态、逻辑位置和容量计量,不包含密钥、邮箱、绝对路径、提示词、图片或供应商原文。</p>
|
|
||||||
{diagnostics ? <pre aria-label="脱敏诊断内容">{diagnostics.diagnostic_text}</pre> : <div aria-label="诊断加载中" className="admin-diagnostics-placeholder" />}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
// 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, AdminDiagnosticsResponse, AdminOverviewResponse, AdminServicesStorageResponse, 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; }
|
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<CreditAdjustmentResponse>;
|
return response.json() as Promise<CreditAdjustmentResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<BrowserSupportSuccess> {
|
export async function checkBrowserSupport(body: BrowserSupportRequest, options: ClientOptions = {}): Promise<BrowserSupportSuccess> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
@@ -87,13 +104,6 @@ export async function getAccountSettings(options: ClientOptions = {}): Promise<A
|
|||||||
return response.json() as Promise<AccountSettingsResponse>;
|
return response.json() as Promise<AccountSettingsResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminDiagnostics(options: ClientOptions = {}): Promise<AdminDiagnosticsResponse> {
|
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/diagnostics`, { method: "GET", headers: options.headers ?? {} });
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
||||||
return response.json() as Promise<AdminDiagnosticsResponse>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAdminOverview(options: ClientOptions = {}): Promise<AdminOverviewResponse> {
|
export async function getAdminOverview(options: ClientOptions = {}): Promise<AdminOverviewResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} });
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} });
|
||||||
@@ -101,11 +111,11 @@ export async function getAdminOverview(options: ClientOptions = {}): Promise<Adm
|
|||||||
return response.json() as Promise<AdminOverviewResponse>;
|
return response.json() as Promise<AdminOverviewResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminServicesStorage(options: ClientOptions = {}): Promise<AdminServicesStorageResponse> {
|
export async function getAdminServices(options: ClientOptions = {}): Promise<AdminServicesResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services-storage`, { method: "GET", headers: options.headers ?? {} });
|
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services`, { method: "GET", headers: options.headers ?? {} });
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
return response.json() as Promise<AdminServicesStorageResponse>;
|
return response.json() as Promise<AdminServicesResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
|
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
|
||||||
@@ -226,6 +236,19 @@ export async function recordRecentAsset(body: RecentAssetRecordRequest, options:
|
|||||||
return response.json() as Promise<RecentAssetRecordResponse>;
|
return response.json() as Promise<RecentAssetRecordResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<ProjectRenameResponse> {
|
export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
@@ -335,3 +358,12 @@ export async function updateAccountProfile(body: AccountProfileUpdateRequest, op
|
|||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
return response.json() as Promise<AccountProfileUpdateResponse>;
|
return response.json() as Promise<AccountProfileUpdateResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateAdminServiceHardLimit(body: AdminServiceLimitRequest, options: ClientOptions = {}): Promise<AdminServicesResponse> {
|
||||||
|
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<AdminServicesResponse>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,21 +60,6 @@ export type AdminCreditParams = {
|
|||||||
"userId": string;
|
"userId": string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminDiagnosticsResponse = {
|
|
||||||
"diagnostic_text": string;
|
|
||||||
"generated_at": string;
|
|
||||||
"services": AdminServicesStorageResponse;
|
|
||||||
"system": {
|
|
||||||
"api_status": "ready" | "degraded" | "unavailable";
|
|
||||||
"app_version": string;
|
|
||||||
"browser_support": Array<{
|
|
||||||
"brand": "Google Chrome" | "Microsoft Edge";
|
|
||||||
"major": number;
|
|
||||||
}>;
|
|
||||||
"worker_status": "ready" | "degraded" | "unavailable";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AdminLoginCompleteRequest = {
|
export type AdminLoginCompleteRequest = {
|
||||||
"registration_id": string;
|
"registration_id": string;
|
||||||
"verification_code": string;
|
"verification_code": string;
|
||||||
@@ -132,27 +117,26 @@ export type AdminOverviewResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminServicesStorageResponse = {
|
export type AdminServiceHealthCheckRequest = {
|
||||||
"generated_at": string;
|
"available": boolean;
|
||||||
"services": Array<{
|
"reason"?: string;
|
||||||
"checked_at": string | null;
|
|
||||||
"configured": boolean;
|
|
||||||
"impact_scope": "none" | "authentication" | "location" | "generation" | "storage" | "api" | "model" | "account" | "unknown";
|
|
||||||
"pause_reason": string | null;
|
|
||||||
"service_id": "resend" | "amap" | "ai_gateway" | "worker" | "api" | "asset_root";
|
|
||||||
"status": "active" | "paused_quota" | "paused_provider" | "disabled" | "degraded" | "unavailable";
|
|
||||||
}>;
|
|
||||||
"storage": {
|
|
||||||
"capacity_notice_level": "normal" | "warning" | "critical";
|
|
||||||
"cleanup_pending_count": number;
|
|
||||||
"data_root_ref": "configured_local_data_root";
|
|
||||||
"hard_limit_bytes": number;
|
|
||||||
"last_measured_at": string | null;
|
|
||||||
"managed_content_bytes": number;
|
|
||||||
"remeasurement_required": boolean;
|
|
||||||
"status": "active" | "full" | "unavailable";
|
|
||||||
"storage_backend": "local_filesystem";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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<ExternalServiceUsage>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminSessionResponse = {
|
export type AdminSessionResponse = {
|
||||||
@@ -394,6 +378,23 @@ export type ErrorEnvelope = {
|
|||||||
|
|
||||||
export type ExportFormat = "jpg" | "png";
|
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 = {
|
export type FailedEmptyTrashRequest = {
|
||||||
"project_ids": Array<ProjectId>;
|
"project_ids": Array<ProjectId>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { UserAuthPage } from "./user-auth.js";
|
|||||||
import { AccountSettingsPage } from "./account-settings.js";
|
import { AccountSettingsPage } from "./account-settings.js";
|
||||||
import { AdminUsersPage } from "./admin-users.js";
|
import { AdminUsersPage } from "./admin-users.js";
|
||||||
import { AdminModelsPage } from "./admin-models.js";
|
import { AdminModelsPage } from "./admin-models.js";
|
||||||
import { AdminServicesStoragePage } from "./admin-services-storage.js";
|
|
||||||
import { CreditsPage } from "./credits-page.js";
|
import { CreditsPage } from "./credits-page.js";
|
||||||
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
|
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
|
||||||
import { EditorPage } from "./editor-page.js";
|
import { EditorPage } from "./editor-page.js";
|
||||||
@@ -46,7 +45,7 @@ function renderAuthenticationEntry() {
|
|||||||
"/admin/invites": { content: <AdminPlaceholderPage title="邀请码" />, title: "邀请码" },
|
"/admin/invites": { content: <AdminPlaceholderPage title="邀请码" />, title: "邀请码" },
|
||||||
"/admin/models": { content: <AdminModelsPage />, title: "模型" },
|
"/admin/models": { content: <AdminModelsPage />, title: "模型" },
|
||||||
"/admin/preview": { content: <AdminPlaceholderPage title="内部预览" />, title: "内部预览" },
|
"/admin/preview": { content: <AdminPlaceholderPage title="内部预览" />, title: "内部预览" },
|
||||||
"/admin/services-storage": { content: <AdminServicesStoragePage />, title: "服务与存储" },
|
"/admin/services-storage": { content: <AdminPlaceholderPage title="服务与存储" />, title: "服务与存储" },
|
||||||
"/admin/users": { content: <AdminUsersPage />, title: "用户与点数" },
|
"/admin/users": { content: <AdminUsersPage />, title: "用户与点数" },
|
||||||
};
|
};
|
||||||
const page = adminPages[window.location.pathname] ?? adminPages["/admin"]!;
|
const page = adminPages[window.location.pathname] ?? adminPages["/admin"]!;
|
||||||
|
|||||||
+587
-444
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -14,7 +14,7 @@
|
|||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
||||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts tests/e2e/wp5-04-resource-isolation.spec.ts tests/e2e/wp6-01-admin-shell.spec.ts tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts",
|
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts tests/e2e/wp5-04-resource-isolation.spec.ts tests/e2e/wp6-01-admin-shell.spec.ts --config playwright.config.ts",
|
||||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||||
@@ -96,8 +96,7 @@
|
|||||||
"test:wp5-04": "node scripts/run-wp5-04-validation.mjs",
|
"test:wp5-04": "node scripts/run-wp5-04-validation.mjs",
|
||||||
"test:wp5-04:red": "node scripts/run-wp5-04-validation.mjs --phase red",
|
"test:wp5-04:red": "node scripts/run-wp5-04-validation.mjs --phase red",
|
||||||
"test:wp6-01": "node scripts/run-wp6-01-validation.mjs --phase scaffold",
|
"test:wp6-01": "node scripts/run-wp6-01-validation.mjs --phase scaffold",
|
||||||
"test:wp6-01:red": "node scripts/run-wp6-01-validation.mjs --phase red",
|
"test:wp6-01:red": "node scripts/run-wp6-01-validation.mjs --phase red"
|
||||||
"test:wp6-05": "pnpm exec vitest run tests/api/wp6-05-state.test.ts && pnpm exec playwright test tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -92,72 +92,3 @@ export const AdminOverviewResponseSchema = Type.Object(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export type AdminOverviewResponse = Static<typeof AdminOverviewResponseSchema>;
|
export type AdminOverviewResponse = Static<typeof AdminOverviewResponseSchema>;
|
||||||
|
|
||||||
const adminServiceStatusSchema = Type.Union([
|
|
||||||
Type.Literal("active"),
|
|
||||||
Type.Literal("paused_quota"),
|
|
||||||
Type.Literal("paused_provider"),
|
|
||||||
Type.Literal("disabled"),
|
|
||||||
Type.Literal("degraded"),
|
|
||||||
Type.Literal("unavailable"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const adminServiceIdSchema = Type.Union([
|
|
||||||
Type.Literal("resend"),
|
|
||||||
Type.Literal("amap"),
|
|
||||||
Type.Literal("ai_gateway"),
|
|
||||||
Type.Literal("worker"),
|
|
||||||
Type.Literal("api"),
|
|
||||||
Type.Literal("asset_root"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const AdminServicesStorageResponseSchema = Type.Object({
|
|
||||||
generated_at: Type.String({ pattern: isoTimestampPattern }),
|
|
||||||
services: Type.Array(Type.Object({
|
|
||||||
checked_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
|
|
||||||
configured: Type.Boolean(),
|
|
||||||
impact_scope: Type.Union([
|
|
||||||
Type.Literal("none"),
|
|
||||||
Type.Literal("authentication"),
|
|
||||||
Type.Literal("location"),
|
|
||||||
Type.Literal("generation"),
|
|
||||||
Type.Literal("storage"),
|
|
||||||
Type.Literal("api"),
|
|
||||||
Type.Literal("model"),
|
|
||||||
Type.Literal("account"),
|
|
||||||
Type.Literal("unknown"),
|
|
||||||
]),
|
|
||||||
pause_reason: Type.Union([Type.String({ maxLength: 80, pattern: "^[a-z][a-z0-9_]*$" }), Type.Null()]),
|
|
||||||
service_id: adminServiceIdSchema,
|
|
||||||
status: adminServiceStatusSchema,
|
|
||||||
}, { additionalProperties: false }), { minItems: 6, maxItems: 6 }),
|
|
||||||
storage: Type.Object({
|
|
||||||
capacity_notice_level: Type.Union([Type.Literal("normal"), Type.Literal("warning"), Type.Literal("critical")]),
|
|
||||||
cleanup_pending_count: Type.Integer({ minimum: 0 }),
|
|
||||||
data_root_ref: Type.Literal("configured_local_data_root"),
|
|
||||||
hard_limit_bytes: Type.Integer({ minimum: 1 }),
|
|
||||||
last_measured_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
|
|
||||||
managed_content_bytes: Type.Integer({ minimum: 0 }),
|
|
||||||
remeasurement_required: Type.Boolean(),
|
|
||||||
status: Type.Union([Type.Literal("active"), Type.Literal("full"), Type.Literal("unavailable")]),
|
|
||||||
storage_backend: Type.Literal("local_filesystem"),
|
|
||||||
}, { additionalProperties: false }),
|
|
||||||
}, { additionalProperties: false, $id: "AdminServicesStorageResponse" });
|
|
||||||
|
|
||||||
export const AdminDiagnosticsResponseSchema = Type.Object({
|
|
||||||
generated_at: Type.String({ pattern: isoTimestampPattern }),
|
|
||||||
diagnostic_text: Type.String({ minLength: 1, maxLength: 12_000 }),
|
|
||||||
services: Type.Ref(AdminServicesStorageResponseSchema),
|
|
||||||
system: Type.Object({
|
|
||||||
api_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]),
|
|
||||||
app_version: Type.String({ maxLength: 80, pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$" }),
|
|
||||||
browser_support: Type.Array(Type.Object({
|
|
||||||
brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]),
|
|
||||||
major: Type.Integer({ minimum: 1 }),
|
|
||||||
}, { additionalProperties: false }), { maxItems: 2 }),
|
|
||||||
worker_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]),
|
|
||||||
}, { additionalProperties: false }),
|
|
||||||
}, { additionalProperties: false, $id: "AdminDiagnosticsResponse" });
|
|
||||||
|
|
||||||
export type AdminServicesStorageResponse = Static<typeof AdminServicesStorageResponseSchema>;
|
|
||||||
export type AdminDiagnosticsResponse = Static<typeof AdminDiagnosticsResponseSchema>;
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { Type } from "@sinclair/typebox";
|
export { Type } from "@sinclair/typebox";
|
||||||
export * from "./api.js";
|
export * from "./api.js";
|
||||||
export * from "./admin.js";
|
export * from "./admin.js";
|
||||||
|
export * from "./services.js";
|
||||||
export * from "./assets.js";
|
export * from "./assets.js";
|
||||||
export * from "./auth.js";
|
export * from "./auth.js";
|
||||||
export * from "./bootstrap.js";
|
export * from "./bootstrap.js";
|
||||||
|
|||||||
@@ -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<typeof ExternalServiceIdSchema>;
|
||||||
|
export type ExternalServicePeriodType = Static<typeof ExternalServicePeriodTypeSchema>;
|
||||||
|
export type ExternalServiceUsage = Static<typeof ExternalServiceUsageSchema>;
|
||||||
|
export type AdminServicesResponse = Static<typeof AdminServicesResponseSchema>;
|
||||||
|
export type AdminServiceLimitRequest = Static<typeof AdminServiceLimitRequestSchema>;
|
||||||
|
export type AdminServiceHealthCheckRequest = Static<typeof AdminServiceHealthCheckRequestSchema>;
|
||||||
|
export type AdminServiceRecoveryRequest = Static<typeof AdminServiceRecoveryRequestSchema>;
|
||||||
|
export type AdminServiceParams = Static<typeof AdminServiceParamsSchema>;
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
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 { createApp } from "../../apps/api/src/app.js";
|
|
||||||
import { createAdminServicesStorageProvider } from "../../apps/api/src/admin-state.js";
|
|
||||||
import { ManagedStorage } from "../../apps/api/src/managed-storage.js";
|
|
||||||
import { ModelConfigurationService } from "../../apps/api/src/model-configuration.js";
|
|
||||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
|
||||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
|
||||||
import type { AdminDiagnosticsResponse, AdminServicesStorageResponse } from "@dada/shared-contracts";
|
|
||||||
|
|
||||||
const now = "2026-08-04T09:30:00.000Z";
|
|
||||||
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
|
||||||
const roots: string[] = [];
|
|
||||||
const registrations: RegistrationService[] = [];
|
|
||||||
const storages: ManagedStorage[] = [];
|
|
||||||
|
|
||||||
const servicesFixture: AdminServicesStorageResponse = {
|
|
||||||
generated_at: now,
|
|
||||||
services: [
|
|
||||||
{ checked_at: now, configured: true, impact_scope: "authentication", pause_reason: null, service_id: "resend", status: "active" },
|
|
||||||
{ checked_at: now, configured: true, impact_scope: "location", pause_reason: "quota_exhausted", service_id: "amap", status: "paused_quota" },
|
|
||||||
{ checked_at: now, configured: true, impact_scope: "generation", pause_reason: "balance_insufficient", service_id: "ai_gateway", status: "degraded" },
|
|
||||||
{ checked_at: now, configured: true, impact_scope: "generation", pause_reason: "worker_stopped", service_id: "worker", status: "degraded" },
|
|
||||||
{ checked_at: now, configured: true, impact_scope: "api", pause_reason: null, service_id: "api", status: "active" },
|
|
||||||
{ checked_at: now, configured: true, impact_scope: "storage", pause_reason: null, service_id: "asset_root", status: "active" },
|
|
||||||
],
|
|
||||||
storage: {
|
|
||||||
capacity_notice_level: "warning",
|
|
||||||
cleanup_pending_count: 2,
|
|
||||||
data_root_ref: "configured_local_data_root",
|
|
||||||
hard_limit_bytes: 5_368_709_120,
|
|
||||||
last_measured_at: now,
|
|
||||||
managed_content_bytes: 4_563_402_752,
|
|
||||||
remeasurement_required: false,
|
|
||||||
status: "active",
|
|
||||||
storage_backend: "local_filesystem",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const diagnosticsFixture: AdminDiagnosticsResponse = {
|
|
||||||
generated_at: now,
|
|
||||||
diagnostic_text: "Dada P0-A\napp_version=0.0.0\napi_status=ready\nworker_status=ready\nstorage_status=active",
|
|
||||||
services: servicesFixture,
|
|
||||||
system: {
|
|
||||||
api_status: "ready",
|
|
||||||
app_version: "0.0.0",
|
|
||||||
browser_support: [{ brand: "Google Chrome", major: 128 }, { brand: "Microsoft Edge", major: 128 }],
|
|
||||||
worker_status: "ready",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function createRegistration() {
|
|
||||||
const root = mkdtempSync(join(tmpdir(), "dada-wp6-05-api-"));
|
|
||||||
roots.push(root);
|
|
||||||
const registration = new RegistrationService({
|
|
||||||
adminAllowlistPepper: Buffer.alloc(32, 0xe1),
|
|
||||||
challengePepper: Buffer.alloc(32, 0xe2),
|
|
||||||
clock: () => Date.parse(now),
|
|
||||||
currentPrivacyNoticeVersion: "p0a-registration-notice-v1",
|
|
||||||
databasePath: join(root, "dada.sqlite3"),
|
|
||||||
invitePepper: Buffer.alloc(32, 0xe3),
|
|
||||||
resend: new MockResendAdapter(),
|
|
||||||
sessionPepper: Buffer.alloc(32, 0xe4),
|
|
||||||
});
|
|
||||||
registrations.push(registration);
|
|
||||||
return registration;
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedAdmin(registration: RegistrationService) {
|
|
||||||
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(), Date.parse(now));
|
|
||||||
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId);
|
|
||||||
return registration.issueAuthenticatedSession(userId, "admin");
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const storage of storages.splice(0)) storage.close();
|
|
||||||
for (const registration of registrations.splice(0)) registration.close();
|
|
||||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("admin status aggregation", () => {
|
|
||||||
it("reads storage and runtime truth without fabricating provider readiness", () => {
|
|
||||||
const registration = createRegistration();
|
|
||||||
const root = roots[roots.length - 1]!;
|
|
||||||
const storage = new ManagedStorage({ dataRoot: root, databasePath: join(root, "dada.sqlite3") });
|
|
||||||
storages.push(storage);
|
|
||||||
const models = new ModelConfigurationService({ database: registration.database, clock: () => Date.parse(now) });
|
|
||||||
const state = createAdminServicesStorageProvider({ database: registration.database, models, storage, clock: () => Date.parse(now) })();
|
|
||||||
expect(new Set(state.services.map((service) => service.service_id)).size).toBe(6);
|
|
||||||
expect(state.services.find((service) => service.service_id === "worker")?.status).toBe("unavailable");
|
|
||||||
expect(state.services.find((service) => service.service_id === "ai_gateway")?.status).toBe("degraded");
|
|
||||||
expect(state.storage.storage_backend).toBe("local_filesystem");
|
|
||||||
expect(JSON.stringify(state)).not.toContain("http");
|
|
||||||
expect(JSON.stringify(state)).not.toContain("@example");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("TDD-WP6-STATE-001-three-model-states", () => {
|
|
||||||
it("requires an active admin session and keeps model/service state provider-backed", async () => {
|
|
||||||
const registration = createRegistration();
|
|
||||||
let serviceCalls = 0;
|
|
||||||
const app = await createApp({
|
|
||||||
adminDiagnostics: async () => diagnosticsFixture,
|
|
||||||
adminServicesStorage: async () => {
|
|
||||||
serviceCalls += 1;
|
|
||||||
return servicesFixture;
|
|
||||||
},
|
|
||||||
browserGate: false,
|
|
||||||
networkBoundary: { allowTestPort: true },
|
|
||||||
registration,
|
|
||||||
});
|
|
||||||
|
|
||||||
const denied = await app.inject({ headers, method: "GET", url: "/api/v1/admin/services-storage" });
|
|
||||||
expect(denied.statusCode).toBe(401);
|
|
||||||
expect(serviceCalls).toBe(0);
|
|
||||||
|
|
||||||
const session = seedAdmin(registration);
|
|
||||||
const authorizedHeaders = { ...headers, cookie: `dada_admin_session=${session.sessionToken}` };
|
|
||||||
const state = await app.inject({ headers: authorizedHeaders, method: "GET", url: "/api/v1/admin/services-storage" });
|
|
||||||
expect(state.statusCode).toBe(200);
|
|
||||||
expect(state.json()).toEqual(servicesFixture);
|
|
||||||
expect(serviceCalls).toBe(1);
|
|
||||||
expect(JSON.stringify(state.json()).toLowerCase()).not.toMatch(/secret|password|email|absolute/);
|
|
||||||
|
|
||||||
const diagnostic = await app.inject({ headers: authorizedHeaders, method: "GET", url: "/api/v1/admin/diagnostics" });
|
|
||||||
expect(diagnostic.statusCode).toBe(200);
|
|
||||||
expect(diagnostic.json()).toEqual(diagnosticsFixture);
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("TDD-WP6-DIAG-001-redacted-diagnostics", () => {
|
|
||||||
it("rejects a provider diagnostic that contains a credential or absolute path trap", async () => {
|
|
||||||
const registration = createRegistration();
|
|
||||||
const app = await createApp({
|
|
||||||
adminDiagnostics: async () => ({ ...diagnosticsFixture, diagnostic_text: "api_key=trap C:\\Users\\dada\\secret.txt" }),
|
|
||||||
browserGate: false,
|
|
||||||
networkBoundary: { allowTestPort: true },
|
|
||||||
registration,
|
|
||||||
});
|
|
||||||
const session = seedAdmin(registration);
|
|
||||||
const response = await app.inject({
|
|
||||||
headers: { ...headers, cookie: `dada_admin_session=${session.sessionToken}` },
|
|
||||||
method: "GET",
|
|
||||||
url: "/api/v1/admin/diagnostics",
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(503);
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a sensitive nested service reason even when diagnostic text is clean", async () => {
|
|
||||||
const registration = createRegistration();
|
|
||||||
const app = await createApp({
|
|
||||||
adminDiagnostics: async () => ({
|
|
||||||
...diagnosticsFixture,
|
|
||||||
services: {
|
|
||||||
...servicesFixture,
|
|
||||||
services: servicesFixture.services.map((service) => service.service_id === "resend" ? { ...service, pause_reason: "password" } : service),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
browserGate: false,
|
|
||||||
networkBoundary: { allowTestPort: true },
|
|
||||||
registration,
|
|
||||||
});
|
|
||||||
const session = seedAdmin(registration);
|
|
||||||
const response = await app.inject({ headers: { ...headers, cookie: `dada_admin_session=${session.sessionToken}` }, method: "GET", url: "/api/v1/admin/diagnostics" });
|
|
||||||
expect(response.statusCode).toBe(503);
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import { createServer, type ViteDevServer } from "vite";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
|
|
||||||
import { adminDiagnosticsFixture, adminServicesStorageFixture } from "../fixtures/wp6-05-state.js";
|
|
||||||
|
|
||||||
let vite: ViteDevServer;
|
|
||||||
let webUrl: string;
|
|
||||||
|
|
||||||
const adminSession = {
|
|
||||||
admin: { role: "super_admin", status: "active", user_id: "00000000-0000-4000-8000-000000000605" },
|
|
||||||
audience: "admin",
|
|
||||||
authenticated: true,
|
|
||||||
expires_at: "2026-09-02T09:30:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
|
||||||
await vite.listen();
|
|
||||||
const address = vite.httpServer?.address();
|
|
||||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
|
||||||
webUrl = `http://127.0.0.1:${address.port}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => vite.close());
|
|
||||||
|
|
||||||
test("TDD-WP6-DIAG-001-redacted-diagnostics renders state and diagnostics without sensitive fields", async ({ page }) => {
|
|
||||||
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify(adminSession), contentType: "application/json", status: 200 }));
|
|
||||||
await page.route("**/api/v1/admin/services-storage", (route) => route.fulfill({ body: JSON.stringify(adminServicesStorageFixture), contentType: "application/json", status: 200 }));
|
|
||||||
await page.route("**/api/v1/admin/diagnostics", (route) => route.fulfill({ body: JSON.stringify(adminDiagnosticsFixture), contentType: "application/json", status: 200 }));
|
|
||||||
await page.goto(`${webUrl}/admin/services-storage`);
|
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { level: 2, name: "服务与存储" })).toBeVisible();
|
|
||||||
await expect(page.getByText("Resend", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText("额度暂停", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText("4.25 GB", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "复制诊断" })).toBeEnabled();
|
|
||||||
await expect(page.locator("body")).not.toContainText(/secret|password|example\.invalid|C:\\Users/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("TDD-WP6-STATE-001-three-model-states refetches REST truth after a runtime event", async ({ page }) => {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
class FakeEventSource {
|
|
||||||
static instance: FakeEventSource | undefined;
|
|
||||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
||||||
constructor() { FakeEventSource.instance = this; }
|
|
||||||
close() { if (FakeEventSource.instance === this) FakeEventSource.instance = undefined; }
|
|
||||||
}
|
|
||||||
Object.defineProperty(window, "EventSource", { configurable: true, value: FakeEventSource });
|
|
||||||
(window as unknown as { emitDadaEvent: () => void }).emitDadaEvent = () => FakeEventSource.instance?.onmessage?.({ data: "{}" } as MessageEvent);
|
|
||||||
});
|
|
||||||
await page.route("**/api/v1/admin-auth/session", (route) => route.fulfill({ body: JSON.stringify({ ...adminSession, csrf_token: "csrf-admin-model-fixture-000000000000000000000000000000000" }), contentType: "application/json", status: 200 }));
|
|
||||||
let runtimeAvailable = false;
|
|
||||||
let modelCalls = 0;
|
|
||||||
const configuration = () => {
|
|
||||||
const ids = ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview", "gpt-image-2"];
|
|
||||||
return {
|
|
||||||
config_set_version: 1,
|
|
||||||
configured_default_model_id: ids[0],
|
|
||||||
recommended_model_id: runtimeAvailable ? ids[1] : null,
|
|
||||||
models: ids.map((modelId, index) => ({
|
|
||||||
config_version: 1,
|
|
||||||
contract_evidence_ref: null,
|
|
||||||
contract_validation_status: "verified",
|
|
||||||
credit_cost: 1,
|
|
||||||
display_name: ["Gemini 3.1 Flash Image Preview", "Gemini 3 Pro Image Preview", "GPT Image 2"][index],
|
|
||||||
enabled: true,
|
|
||||||
error_mapping_profile: { timeout: "upstream_timeout" },
|
|
||||||
gateway_account_ref: "mock-gateway",
|
|
||||||
is_default: index === 0,
|
|
||||||
model_id: modelId,
|
|
||||||
prompt_max_length: 1_000,
|
|
||||||
recommendation_priority: index + 1,
|
|
||||||
reference_limits: { max_file_bytes: 1_024, max_files: 2, max_total_bytes: 2_048 },
|
|
||||||
route_profile: { endpoint: "https://mock.invalid/v1/images" },
|
|
||||||
runtime_availability: { available_for_new_jobs: runtimeAvailable, checked_at: "2026-08-02T15:00:00.000Z", reason: runtimeAvailable ? "available" : "gateway_balance_insufficient" },
|
|
||||||
safety_source: "provider",
|
|
||||||
supported_ratios: ["3:4", "1:1", "4:3", "9:16"],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
await page.route("**/api/v1/models", (route) => {
|
|
||||||
modelCalls += 1;
|
|
||||||
return route.fulfill({ body: JSON.stringify(configuration()), contentType: "application/json", status: 200 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(`${webUrl}/admin/models`);
|
|
||||||
await expect(page.getByText("当前推荐").locator("..").getByText("无", { exact: true })).toBeVisible();
|
|
||||||
expect(modelCalls).toBeGreaterThanOrEqual(1);
|
|
||||||
runtimeAvailable = true;
|
|
||||||
await page.evaluate(() => (window as unknown as { emitDadaEvent: () => void }).emitDadaEvent());
|
|
||||||
await expect(page.getByText("当前推荐").locator("..").getByText("gemini-3-pro-image-preview", { exact: true })).toBeVisible();
|
|
||||||
await expect(page.getByText("配置默认").locator("..").getByText("gemini-3.1-flash-image-preview", { exact: true })).toBeVisible();
|
|
||||||
expect(modelCalls).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
Vendored
-34
@@ -1,34 +0,0 @@
|
|||||||
export const adminServicesStorageFixture = {
|
|
||||||
generated_at: "2026-08-04T09:30:00.000Z",
|
|
||||||
services: [
|
|
||||||
{ checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "authentication" as const, pause_reason: null, service_id: "resend" as const, status: "active" as const },
|
|
||||||
{ checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "location" as const, pause_reason: "quota_exhausted", service_id: "amap" as const, status: "paused_quota" as const },
|
|
||||||
{ checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "generation" as const, pause_reason: "balance_insufficient", service_id: "ai_gateway" as const, status: "degraded" as const },
|
|
||||||
{ checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "generation" as const, pause_reason: "worker_stopped", service_id: "worker" as const, status: "degraded" as const },
|
|
||||||
{ checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "api" as const, pause_reason: null, service_id: "api" as const, status: "active" as const },
|
|
||||||
{ checked_at: "2026-08-04T09:30:00.000Z", configured: true, impact_scope: "storage" as const, pause_reason: null, service_id: "asset_root" as const, status: "active" as const },
|
|
||||||
],
|
|
||||||
storage: {
|
|
||||||
capacity_notice_level: "warning" as const,
|
|
||||||
cleanup_pending_count: 2,
|
|
||||||
data_root_ref: "configured_local_data_root" as const,
|
|
||||||
hard_limit_bytes: 5_368_709_120,
|
|
||||||
last_measured_at: "2026-08-04T09:30:00.000Z",
|
|
||||||
managed_content_bytes: 4_563_402_752,
|
|
||||||
remeasurement_required: false,
|
|
||||||
status: "active" as const,
|
|
||||||
storage_backend: "local_filesystem" as const,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const adminDiagnosticsFixture = {
|
|
||||||
generated_at: "2026-08-04T09:30:00.000Z",
|
|
||||||
diagnostic_text: "Dada P0-A diagnostics\napp_version=0.0.0\napi_status=ready\nworker_status=ready\nstorage_status=active",
|
|
||||||
services: adminServicesStorageFixture,
|
|
||||||
system: {
|
|
||||||
api_status: "ready" as const,
|
|
||||||
app_version: "0.0.0",
|
|
||||||
browser_support: [{ brand: "Google Chrome" as const, major: 128 }],
|
|
||||||
worker_status: "ready" as const,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user