Compare commits

..
Author SHA1 Message Date
suyx d738ea175e feat: implement TASK-WP6-02 private content access
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 10m0s
2026-08-04 02:14:29 +08:00
16 changed files with 1305 additions and 1469 deletions
+195 -174
View File
@@ -10,16 +10,9 @@ import {
AccountProfileUpdateResponseSchema, AccountProfileUpdateResponseSchema,
AccountSettingsResponseSchema, AccountSettingsResponseSchema,
AdminAuthenticatedUserSchema, AdminAuthenticatedUserSchema,
AdminGenerationRecordSchema,
AdminGenerationListResponseSchema,
AdminOverviewResponseSchema, AdminOverviewResponseSchema,
AdminServicesResponseSchema,
AdminServiceHealthCheckRequestSchema,
AdminServiceLimitRequestSchema,
AdminServiceParamsSchema,
AdminServiceRecoveryRequestSchema,
ExternalServiceIdSchema,
ExternalServicePeriodTypeSchema,
ExternalServiceStatusSchema,
ExternalServiceUsageSchema,
AdminCreditParamsSchema, AdminCreditParamsSchema,
AdminLoginCompleteRequestSchema, AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema, AdminLoginCompleteResponseSchema,
@@ -70,6 +63,10 @@ import {
ModelConfigUpdateRequestSchema, ModelConfigUpdateRequestSchema,
ModelParamsSchema, ModelParamsSchema,
ModelConfigUpdateHeadersSchema, ModelConfigUpdateHeadersSchema,
PrivateContentGenerationParamsSchema,
PrivateContentNoticeAckRequestSchema,
PrivateContentNoticeAckResponseSchema,
PrivateContentPromptResponseSchema,
FailedEmptyTrashRequestSchema, FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema, FailedEmptyTrashResponseSchema,
ExportFormatSchema, ExportFormatSchema,
@@ -187,9 +184,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 { PrivateContentError, PrivateContentService } from "./private-content.js";
const defaultBootstrap: BootstrapResponse = { const defaultBootstrap: BootstrapResponse = {
app_version: "0.0.0", app_version: "0.0.0",
@@ -231,8 +228,8 @@ export interface CreateAppOptions {
releaseVersion: string; releaseVersion: string;
resourceId: string; resourceId: string;
}) => boolean | Promise<boolean>; }) => boolean | Promise<boolean>;
privateContent?: PrivateContentService;
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");
@@ -651,6 +648,13 @@ function sendBrowserUnsupported(
export async function createApp(options: CreateAppOptions = {}) { export async function createApp(options: CreateAppOptions = {}) {
const eventHub = options.eventHub ?? new EventHub(); const eventHub = options.eventHub ?? new EventHub();
const bootstrap = options.bootstrap ?? (() => defaultBootstrap); const bootstrap = options.bootstrap ?? (() => defaultBootstrap);
const privateContent = options.privateContent ?? (options.registration
? new PrivateContentService(
options.registration.database,
options.registration.options.currentPrivacyNoticeVersion,
options.registration.options.clock,
)
: undefined);
const browserGate = options.browserGate ?? true; const browserGate = options.browserGate ?? true;
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32); const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
const browserSupportRelease = options.browserSupportRelease; const browserSupportRelease = options.browserSupportRelease;
@@ -702,16 +706,13 @@ export async function createApp(options: CreateAppOptions = {}) {
AdminLoginCompleteRequestSchema, AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema, AdminLoginCompleteResponseSchema,
AdminSessionResponseSchema, AdminSessionResponseSchema,
AdminGenerationRecordSchema,
AdminGenerationListResponseSchema,
PrivateContentNoticeAckRequestSchema,
PrivateContentNoticeAckResponseSchema,
PrivateContentPromptResponseSchema,
PrivateContentGenerationParamsSchema,
AdminOverviewResponseSchema, AdminOverviewResponseSchema,
AdminServicesResponseSchema,
AdminServiceHealthCheckRequestSchema,
AdminServiceLimitRequestSchema,
AdminServiceParamsSchema,
AdminServiceRecoveryRequestSchema,
ExternalServiceIdSchema,
ExternalServicePeriodTypeSchema,
ExternalServiceStatusSchema,
ExternalServiceUsageSchema,
CreditSummarySchema, CreditSummarySchema,
CreditEntryTypeSchema, CreditEntryTypeSchema,
CreditEntryStatusSchema, CreditEntryStatusSchema,
@@ -853,6 +854,167 @@ export async function createApp(options: CreateAppOptions = {}) {
status: "ready", status: "ready",
})); }));
const readAdminRequestSession = (request: { headers: Record<string, string | string[] | undefined> }) => {
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
return token && options.registration ? options.registration.readAdminSession(token) : undefined;
};
const privateContentNoticeRequired = (reply: FastifyReply, correlationId: string) => {
const notice = privateContent?.currentNotice();
return reply.code(428).send(createErrorEnvelope({
code: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED",
correlationId,
details: { latest_version: notice?.version ?? "" },
}));
};
app.post(
"/api/v1/admin/private-content-notice/ack",
{
attachValidation: true,
schema: {
body: Type.Ref(PrivateContentNoticeAckRequestSchema),
headers: Type.Intersect([Type.Ref(CsrfHeadersSchema), Type.Ref(RegistrationCompleteHeadersSchema)]),
operationId: "ackPrivateContentNotice",
response: {
200: Type.Ref(PrivateContentNoticeAckResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
428: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Admin Private Content"],
},
},
async (request, reply) => {
if (request.validationError || !headerValue(request.headers["idempotency-key"])) {
return reply.code(400).send(createErrorEnvelope({
code: "REGISTRATION_REQUEST_INVALID",
correlationId: request.id,
details: { field_errors: [{ field: "headers", message_key: "request.headers.invalid" }] },
}));
}
if (!privateContent || !options.registration) 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 admin = options.registration.authorizeAdminMutation({
csrfToken: headerValue(request.headers["x-csrf-token"]) ?? "",
sessionToken: token,
});
const result = privateContent.acknowledge(admin.userId, (request.body as { expected_notice_version: string }).expected_notice_version);
return { acknowledged_at: result.acknowledgedAt, notice_version: result.noticeVersion, status: "acknowledged" as const };
} catch (error) {
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
if (error instanceof PrivateContentError && error.code === "notice_version_conflict") return privateContentNoticeRequired(reply, request.id);
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.get(
"/api/v1/admin/generations",
{
schema: {
operationId: "listAdminGenerations",
response: {
200: Type.Ref(AdminGenerationListResponseSchema),
401: Type.Ref(ErrorEnvelopeSchema),
428: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Admin Private Content"],
},
},
async (request, reply) => {
if (!privateContent || !options.registration) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const session = readAdminRequestSession(request);
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
privateContent.requireAcknowledgement(session.user_id);
return privateContent.listGenerations();
} catch (error) {
if (error instanceof PrivateContentError && error.code === "notice_required") return privateContentNoticeRequired(reply, request.id);
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.get(
"/api/v1/admin/private-content/generations/:generationId/prompt",
{
attachValidation: true,
schema: {
params: Type.Ref(PrivateContentGenerationParamsSchema),
operationId: "openAdminGenerationPrompt",
response: {
200: Type.Ref(PrivateContentPromptResponseSchema),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
428: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Admin Private Content"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(404).send(null);
if (!privateContent || !options.registration) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const session = readAdminRequestSession(request);
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const generationId = (request.params as { generationId: string }).generationId;
const value = privateContent.readPrompt(session.user_id, generationId);
reply.header("Cache-Control", "private, no-store");
return { content_type: "prompt" as const, generation_id: value.generationId, prompt: value.prompt };
} catch (error) {
if (error instanceof PrivateContentError && error.code === "notice_required") return privateContentNoticeRequired(reply, request.id);
if (error instanceof PrivateContentError && error.code === "not_found") return reply.code(404).send(null);
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.get(
"/api/v1/admin/private-content/generations/:generationId/image",
{
attachValidation: true,
schema: {
params: Type.Ref(PrivateContentGenerationParamsSchema),
operationId: "openAdminGenerationImage",
produces: ["application/octet-stream"],
response: {
200: Type.String({ format: "binary" }),
401: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
428: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Admin Private Content"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(404).send(null);
if (!privateContent || !options.registration || !options.latestExports) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const session = readAdminRequestSession(request);
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const generationId = (request.params as { generationId: string }).generationId;
const target = privateContent.readImageTarget(session.user_id, generationId);
const item = options.latestExports.getOriginal(target.ownerId, target.projectId, target.imageId);
const extension = item.mime_type === "image/jpeg" ? "jpg" : item.mime_type === "image/webp" ? "webp" : "png";
reply.header("Cache-Control", "private, no-store");
reply.header("Content-Disposition", `inline; filename="dada-generation.${extension}"`);
reply.type(item.mime_type);
return reply.send(createReadStream(item.path));
} catch (error) {
if (error instanceof PrivateContentError && error.code === "notice_required") return privateContentNoticeRequired(reply, request.id);
if (error instanceof PrivateContentError && error.code === "not_found") return reply.code(404).send(null);
return latestExportFailure(reply, request.id, error);
}
},
);
app.get( app.get(
"/api/v1/assets/public/:resourceVersion/manifest", "/api/v1/assets/public/:resourceVersion/manifest",
{ schema: { hide: true } }, { schema: { hide: true } },
@@ -981,6 +1143,13 @@ export async function createApp(options: CreateAppOptions = {}) {
}) })
: false; : false;
if (userSession?.userId !== resource.ownerId && !controlledAdmin) return reply.code(404).send(); if (userSession?.userId !== resource.ownerId && !controlledAdmin) return reply.code(404).send();
if (adminSession && controlledAdmin && privateContent) {
try {
privateContent.recordPrivateAssetAccess(adminSession.user_id, resource.ownerId, resource.resourceId);
} catch {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
}
reply.type(resource.mimeType); reply.type(resource.mimeType);
reply.header("Cache-Control", "private, no-store"); reply.header("Cache-Control", "private, no-store");
reply.header("Content-Disposition", "inline"); reply.header("Content-Disposition", "inline");
@@ -1084,16 +1253,10 @@ 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);
} }
}, },
@@ -1220,15 +1383,18 @@ export async function createApp(options: CreateAppOptions = {}) {
if (!session) { if (!session) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
} }
const acknowledgement = privateContent?.readAcknowledgement(session.user_id);
const notice = privateContent?.currentNotice();
return { return {
acknowledged_private_content_notice_version: null, acknowledged_private_content_notice_version: acknowledgement?.version ?? null,
admin: { role: "super_admin" as const, status: "active" as const, user_id: session.user_id }, admin: { role: "super_admin" as const, status: "active" as const, user_id: session.user_id },
audience: "admin" as const, audience: "admin" as const,
authenticated: true as const, authenticated: true as const,
csrf_token: options.registration.issueAdminCsrfToken(token!), csrf_token: options.registration.issueAdminCsrfToken(token!),
current_private_content_notice_version: null, ...(notice ? { current_private_content_notice_message_key: notice.messageKey } : {}),
current_private_content_notice_version: notice?.version ?? null,
expires_at: new Date(session.expires_at).toISOString(), expires_at: new Date(session.expires_at).toISOString(),
notice_acknowledged: false, notice_acknowledged: notice ? acknowledgement?.version === notice.version : false,
}; };
}, },
); );
@@ -1262,151 +1428,6 @@ export async function createApp(options: CreateAppOptions = {}) {
}, },
); );
app.get(
"/api/v1/admin/services",
{
schema: {
operationId: "getAdminServices",
response: { 200: Type.Ref(AdminServicesResponseSchema), 401: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) },
tags: ["Admin Services"],
},
},
async (request, reply) => {
const registration = options.registration;
const usage = options.serviceUsage ?? registration?.serviceUsage;
if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
const session = token ? registration.readAdminSession(token) : undefined;
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
return {
services: usage.readCurrent().map((row) => ({
hard_limit: row.hardLimit,
pause_reason: row.pauseReason,
period_start: new Date(row.periodStart).toISOString(),
period_type: row.periodType,
service_id: row.serviceId,
service_status: row.status,
updated_at: new Date(row.updatedAt).toISOString(),
used_count: row.usedCount,
})),
};
},
);
app.post(
"/api/v1/admin/services/:service_id/health-check",
{
attachValidation: true,
schema: {
params: Type.Ref(AdminServiceParamsSchema),
body: Type.Ref(AdminServiceHealthCheckRequestSchema),
headers: Type.Ref(ModelConfigUpdateHeadersSchema),
operationId: "checkAdminServiceHealth",
response: { 200: Type.Object({ check_id: Type.String(), available: Type.Boolean(), checked_at: Type.String() }, { additionalProperties: false }), 400: Type.Ref(ErrorEnvelopeSchema), 401: Type.Ref(ErrorEnvelopeSchema), 403: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) },
tags: ["Admin Services"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(createErrorEnvelope({ code: "REGISTRATION_REQUEST_INVALID", correlationId: request.id }));
const registration = options.registration;
const usage = options.serviceUsage ?? registration?.serviceUsage;
if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const headers = request.headers as { "x-csrf-token": string };
registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const body = request.body as { available: boolean; reason?: string };
const params = request.params as { service_id: "resend_email" | "amap_web_service" };
const check = usage.recordHealthCheck({ serviceId: params.service_id, available: body.available, ...(body.reason ? { reason: body.reason } : {}) });
return { check_id: check.checkId, available: check.available, checked_at: new Date(check.checkedAt).toISOString() };
} catch (error) {
if (error instanceof RegistrationError) return reply.code(error.httpStatus).send(createErrorEnvelope({ code: error.code, correlationId: request.id }));
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.patch(
"/api/v1/admin/services/:service_id/limits",
{
attachValidation: true,
schema: {
params: Type.Ref(AdminServiceParamsSchema),
body: Type.Ref(AdminServiceLimitRequestSchema),
headers: Type.Ref(ModelConfigUpdateHeadersSchema),
operationId: "updateAdminServiceHardLimit",
response: { 200: Type.Ref(AdminServicesResponseSchema), 400: Type.Ref(ErrorEnvelopeSchema), 401: Type.Ref(ErrorEnvelopeSchema), 403: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) },
tags: ["Admin Services"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(createErrorEnvelope({ code: "REGISTRATION_REQUEST_INVALID", correlationId: request.id }));
const registration = options.registration;
const usage = options.serviceUsage ?? registration?.serviceUsage;
if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const headers = request.headers as { "x-csrf-token": string };
const admin = registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const body = request.body as { hard_limit: number; period_type: "daily" | "monthly" };
const params = request.params as { service_id: "resend_email" | "amap_web_service" };
usage.setHardLimit({ actorId: admin.userId, hardLimit: body.hard_limit, periodType: body.period_type, serviceId: params.service_id });
return {
services: usage.readCurrent().map((row) => ({
hard_limit: row.hardLimit,
pause_reason: row.pauseReason,
period_start: new Date(row.periodStart).toISOString(),
period_type: row.periodType,
service_id: row.serviceId,
service_status: row.status,
updated_at: new Date(row.updatedAt).toISOString(),
used_count: row.usedCount,
})),
};
} catch (error) {
if (error instanceof RegistrationError) return reply.code(error.httpStatus).send(createErrorEnvelope({ code: error.code, correlationId: request.id }));
if (error instanceof ExternalServiceUsageError) return reply.code(409).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.post(
"/api/v1/admin/services/:service_id/recover",
{
attachValidation: true,
schema: {
params: Type.Ref(AdminServiceParamsSchema),
body: Type.Ref(AdminServiceRecoveryRequestSchema),
headers: Type.Ref(ModelConfigUpdateHeadersSchema),
operationId: "recoverAdminService",
response: { 200: Type.Object({ status: Type.Literal("active") }, { additionalProperties: false }), 400: Type.Ref(ErrorEnvelopeSchema), 401: Type.Ref(ErrorEnvelopeSchema), 403: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) },
tags: ["Admin Services"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(createErrorEnvelope({ code: "REGISTRATION_REQUEST_INVALID", correlationId: request.id }));
const registration = options.registration;
const usage = options.serviceUsage ?? registration?.serviceUsage;
if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const headers = request.headers as { "x-csrf-token": string };
const admin = registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const body = request.body as { check_id: string };
const params = request.params as { service_id: "resend_email" | "amap_web_service" };
return usage.recover({ actorId: admin.userId, checkId: body.check_id, serviceId: params.service_id });
} catch (error) {
if (error instanceof RegistrationError) return reply.code(error.httpStatus).send(createErrorEnvelope({ code: error.code, correlationId: request.id }));
if (error instanceof ExternalServiceUsageError) return reply.code(409).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.post( app.post(
"/api/v1/auth/login/send", "/api/v1/auth/login/send",
{ {
-459
View File
@@ -1,459 +0,0 @@
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";
}
+186
View File
@@ -0,0 +1,186 @@
import { randomUUID } from "node:crypto";
import type BetterSqlite3 from "better-sqlite3";
import { auditRetentionMilliseconds } from "./audit-policy.js";
type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
export class PrivateContentError extends Error {
constructor(readonly code: "notice_required" | "notice_version_conflict" | "not_found") {
super(code);
this.name = "PrivateContentError";
}
}
function iso(value: number) {
return new Date(value).toISOString();
}
function isGenerationTablePresent(database: BetterSqlite3.Database) {
return Boolean(database.prepare(
"SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'generation_jobs'",
).get());
}
export class PrivateContentService {
constructor(
readonly database: BetterSqlite3.Database,
readonly currentNoticeVersion: string,
private readonly clock: () => number = Date.now,
) {}
currentNotice() {
return {
version: this.currentNoticeVersion,
messageKey: "admin.private_content.notice",
} as const;
}
readAcknowledgement(adminUserId: string) {
const row = this.database.prepare(`
SELECT private_content_notice_version, private_content_notice_acknowledged_at
FROM user_profiles WHERE user_id = ?
`).get(adminUserId) as { private_content_notice_version: string | null; private_content_notice_acknowledged_at: number | null } | undefined;
return {
version: row?.private_content_notice_version ?? null,
acknowledgedAt: row?.private_content_notice_acknowledged_at === null || row?.private_content_notice_acknowledged_at === undefined
? null : iso(row.private_content_notice_acknowledged_at),
};
}
isAcknowledged(adminUserId: string) {
return this.readAcknowledgement(adminUserId).version === this.currentNoticeVersion;
}
requireAcknowledgement(adminUserId: string) {
if (!this.isAcknowledged(adminUserId)) throw new PrivateContentError("notice_required");
}
acknowledge(adminUserId: string, expectedNoticeVersion: string) {
const now = this.clock();
return this.database.transaction(() => {
if (expectedNoticeVersion !== this.currentNoticeVersion) {
throw new PrivateContentError("notice_version_conflict");
}
this.database.prepare(`
INSERT INTO user_profiles (
user_id, creator_name, social_id, private_content_notice_version,
private_content_notice_acknowledged_at
) VALUES (?, '', '', ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
private_content_notice_version = excluded.private_content_notice_version,
private_content_notice_acknowledged_at =
CASE WHEN user_profiles.private_content_notice_version = excluded.private_content_notice_version
THEN user_profiles.private_content_notice_acknowledged_at ELSE excluded.private_content_notice_acknowledged_at END
`).run(adminUserId, this.currentNoticeVersion, now);
const acknowledged = this.readAcknowledgement(adminUserId);
return {
noticeVersion: this.currentNoticeVersion,
acknowledgedAt: acknowledged.acknowledgedAt ?? iso(now),
};
})();
}
listGenerations() {
const generatedAt = iso(this.clock());
if (!isGenerationTablePresent(this.database)) return { generated_at: generatedAt, items: [] };
const rows = this.database.prepare(`
SELECT generation_id, owner_id, project_id, model_id, ratio, status,
confirmed_credit_cost, reserved_credits, final_credit_state,
error_category, created_at, updated_at
FROM generation_jobs
WHERE submission_ready = 1
ORDER BY created_at DESC, generation_id DESC
LIMIT 100
`).all() as Array<{
generation_id: string;
owner_id: string;
project_id: string;
model_id: string;
ratio: "3:4" | "1:1" | "4:3" | "9:16";
status: GenerationStatus;
confirmed_credit_cost: number;
reserved_credits: number;
final_credit_state: "committed" | "released" | null;
error_category: string | null;
created_at: number;
updated_at: number;
}>;
return {
generated_at: generatedAt,
items: rows.map((row) => {
const terminal = row.status === "succeeded" || row.status === "failed" || row.status === "rejected";
return {
generation_id: row.generation_id,
owner_ref: row.owner_id,
project_id: row.project_id,
model_id: row.model_id,
ratio: row.ratio,
status: row.status,
created_at: iso(row.created_at),
completed_at: terminal ? iso(row.updated_at) : null,
duration_ms: terminal ? Math.max(0, row.updated_at - row.created_at) : null,
confirmed_credit_cost: row.confirmed_credit_cost,
reserved_credits: row.reserved_credits,
final_credit_state: row.final_credit_state,
error_category: row.error_category,
};
}),
};
}
private generation(generationId: string) {
if (!isGenerationTablePresent(this.database)) throw new PrivateContentError("not_found");
const row = this.database.prepare(`
SELECT generation_id, owner_id, project_id
FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1
`).get(generationId) as { generation_id: string; owner_id: string; project_id: string } | undefined;
if (!row) throw new PrivateContentError("not_found");
return row;
}
private recordAccess(input: { adminUserId: string; ownerId: string; generationId: string; contentType: "image" | "prompt" }) {
const now = this.clock();
// The insert is committed before the caller reads the private value. A failed
// constraint therefore cannot accidentally release a private response.
this.database.transaction(() => {
this.database.prepare(`
INSERT INTO private_content_access_logs (
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
randomUUID(), input.adminUserId, input.ownerId, input.generationId,
input.contentType, now, now + auditRetentionMilliseconds,
);
})();
}
recordPrivateAssetAccess(adminUserId: string, ownerId: string, resourceId: string) {
this.recordAccess({ adminUserId, ownerId, generationId: resourceId, contentType: "image" });
}
readPrompt(adminUserId: string, generationId: string) {
this.requireAcknowledgement(adminUserId);
const row = this.generation(generationId);
this.recordAccess({ adminUserId, ownerId: row.owner_id, generationId: row.generation_id, contentType: "prompt" });
const content = this.database.prepare(
"SELECT prompt FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1",
).get(row.generation_id) as { prompt: string } | undefined;
if (!content) throw new PrivateContentError("not_found");
return { generationId: row.generation_id, prompt: content.prompt };
}
readImageTarget(adminUserId: string, generationId: string) {
this.requireAcknowledgement(adminUserId);
const row = this.database.prepare(`
SELECT g.generation_id, g.owner_id, g.project_id, pi.image_id
FROM generation_jobs g
JOIN project_images pi ON pi.project_id = g.project_id AND pi.generation_id = g.generation_id
WHERE g.generation_id = ? AND g.status = 'succeeded'
ORDER BY pi.created_at DESC LIMIT 1
`).get(generationId) as { generation_id: string; owner_id: string; project_id: string; image_id: string } | undefined;
if (!row) throw new PrivateContentError("not_found");
this.recordAccess({ adminUserId, ownerId: row.owner_id, generationId: row.generation_id, contentType: "image" });
return { projectId: row.project_id, imageId: row.image_id, ownerId: row.owner_id };
}
}
-11
View File
@@ -11,7 +11,6 @@ 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,
@@ -227,7 +226,6 @@ 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;
@@ -256,7 +254,6 @@ 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() {
@@ -302,7 +299,6 @@ 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 (
@@ -332,7 +328,6 @@ 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 };
@@ -360,7 +355,6 @@ 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,
@@ -388,7 +382,6 @@ 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 };
@@ -775,7 +768,6 @@ 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,
@@ -803,7 +795,6 @@ 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);
@@ -1106,7 +1097,6 @@ export class RegistrationService {
now + resendDelayMilliseconds, now + resendDelayMilliseconds,
now, now,
); );
this.serviceUsage.claimResendWithinTransaction(now);
return { return {
outcome: "committed", outcome: "committed",
value: { value: {
@@ -1126,7 +1116,6 @@ 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 };
+24
View File
@@ -0,0 +1,24 @@
.admin-generations { display: grid; gap: 20px; }
.admin-generations-refresh { align-self: start; }
.admin-generations-notice-panel { display: grid; gap: 14px; max-width: 760px; padding: 24px; border: 1px solid #d5b36a; background: #fffaf0; }
.admin-generations-notice-panel p { margin: 0; }
.admin-generations-notice-panel button { justify-self: start; }
.admin-generations-error, .admin-generations-notice { padding: 12px 16px; border: 1px solid #d46a6a; background: #fff4f4; }
.admin-generations-error button { margin-left: 12px; }
.admin-generations-table-wrap { overflow-x: auto; border: 1px solid #d9dde5; background: #fff; }
.admin-generations-table-wrap table { width: 100%; min-width: 1050px; border-collapse: collapse; }
.admin-generations-table-wrap th, .admin-generations-table-wrap td { padding: 12px 14px; border-bottom: 1px solid #e9ebef; text-align: left; vertical-align: top; }
.admin-generations-table-wrap th { background: #f5f6f8; color: #4d5664; font-size: 12px; }
.admin-generations-table-wrap small { color: #6c7481; }
.admin-generations-status { display: inline-block; padding: 3px 7px; border-radius: 4px; background: #edf0f4; }
.admin-generations-status.is-succeeded { color: #23623d; background: #e6f4ea; }
.admin-generations-status.is-failed, .admin-generations-status.is-rejected { color: #8b2b2b; background: #fff0f0; }
.admin-generations-status.is-running { color: #7a5a10; background: #fff5d8; }
.admin-generations-actions { display: grid; gap: 8px; min-width: 190px; }
.admin-generations-actions button { white-space: normal; }
.admin-generations-empty { margin: 0; padding: 28px; color: #6c7481; }
.admin-generations-opened { display: grid; gap: 10px; padding: 18px; border: 1px solid #cbd2dd; background: #fff; }
.admin-generations-opened header { display: flex; align-items: center; justify-content: space-between; }
.admin-generations-opened h3 { margin: 0; }
.admin-generations-opened pre { max-height: 360px; overflow: auto; margin: 0; padding: 14px; white-space: pre-wrap; background: #f6f7f9; }
.admin-generations-opened img { max-width: 100%; max-height: 620px; object-fit: contain; }
+156
View File
@@ -0,0 +1,156 @@
import { useEffect, useState } from "react";
import "./admin-generations.css";
interface AdminSession {
acknowledged_private_content_notice_version: string | null;
current_private_content_notice_version: string | null;
csrf_token: string;
notice_acknowledged: boolean;
}
interface GenerationRecord {
generation_id: string;
owner_ref: string;
project_id: string;
model_id: string;
ratio: string;
status: "queued" | "running" | "succeeded" | "failed" | "rejected";
created_at: string;
completed_at: string | null;
duration_ms: number | null;
confirmed_credit_cost: number;
reserved_credits: number;
final_credit_state: "committed" | "released" | null;
error_category: string | null;
}
interface GenerationResponse { generated_at: string; items: GenerationRecord[] }
interface OpenedPrompt { generation_id: string; prompt: string }
function idempotencyKey() {
return `${crypto.randomUUID().replaceAll("-", "")}${crypto.randomUUID().replaceAll("-", "")}`;
}
function compactId(value: string) { return `${value.slice(0, 8)}...${value.slice(-4)}`; }
function formatTime(value: string | null) { return value ? new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", timeStyle: "medium" }).format(new Date(value)) : "未完成"; }
function statusLabel(value: GenerationRecord["status"]) { return { queued: "排队", running: "运行中", succeeded: "成功", failed: "失败", rejected: "已拒绝" }[value]; }
export function AdminGenerationsPage() {
const [session, setSession] = useState<AdminSession>();
const [records, setRecords] = useState<GenerationRecord[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [acknowledging, setAcknowledging] = useState(false);
const [notice, setNotice] = useState("");
const [openedPrompt, setOpenedPrompt] = useState<OpenedPrompt>();
const [openedImage, setOpenedImage] = useState<{ generationId: string; url: string }>();
async function load() {
setLoading(true);
setFailed(false);
try {
const sessionResponse = await fetch("/api/v1/admin-auth/session", { credentials: "same-origin" });
if (sessionResponse.status === 401) throw new Error("session_invalid");
if (!sessionResponse.ok) throw new Error("session_unavailable");
const current = await sessionResponse.json() as AdminSession;
setSession(current);
setNotice("");
if (!current.notice_acknowledged) {
setRecords([]);
return;
}
const listResponse = await fetch("/api/v1/admin/generations", { credentials: "same-origin" });
if (!listResponse.ok) throw new Error("generation_list_unavailable");
setRecords((await listResponse.json() as GenerationResponse).items);
} catch {
setFailed(true);
} finally {
setLoading(false);
}
}
useEffect(() => { void load(); }, []);
useEffect(() => () => { if (openedImage) URL.revokeObjectURL(openedImage.url); }, [openedImage]);
async function acknowledge() {
if (!session?.current_private_content_notice_version || acknowledging) return;
setAcknowledging(true);
setNotice("");
try {
const response = await fetch("/api/v1/admin/private-content-notice/ack", {
body: JSON.stringify({ expected_notice_version: session.current_private_content_notice_version }),
credentials: "same-origin",
headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey(), "X-CSRF-Token": session.csrf_token },
method: "POST",
});
if (!response.ok) throw new Error("notice_ack_failed");
await load();
} catch {
setNotice("告知版本已变化或确认未完成,请重新读取。 ");
} finally {
setAcknowledging(false);
}
}
async function openPrompt(generationId: string) {
setNotice("");
try {
const response = await fetch(`/api/v1/admin/private-content/generations/${generationId}/prompt`, { credentials: "same-origin" });
if (!response.ok) throw new Error("prompt_unavailable");
setOpenedPrompt(await response.json() as OpenedPrompt);
} catch {
setNotice("内容读取未完成,访问审计未成功时不会返回内容。 ");
}
}
async function openImage(generationId: string) {
setNotice("");
try {
const response = await fetch(`/api/v1/admin/private-content/generations/${generationId}/image`, { credentials: "same-origin" });
if (!response.ok) throw new Error("image_unavailable");
const url = URL.createObjectURL(await response.blob());
setOpenedImage((previous) => {
if (previous) URL.revokeObjectURL(previous.url);
return { generationId, url };
});
} catch {
setNotice("内容读取未完成,访问审计未成功时不会返回内容。 ");
}
}
return (
<main className="admin-generations" id="admin-main">
<header className="admin-page-heading"><div><p>OPERATIONS / GENERATION RECORDS</p><h2></h2></div><button className="admin-generations-refresh" onClick={() => void load()} type="button"></button></header>
{loading ? <p aria-live="polite"></p> : null}
{failed ? <div className="admin-generations-error" role="alert"><button onClick={() => void load()} type="button"></button></div> : null}
{notice ? <p className="admin-generations-notice" role="alert">{notice}</p> : null}
{session && !session.notice_acknowledged ? (
<section aria-labelledby="private-content-notice-title" className="admin-generations-notice-panel">
<p>PRIVATE CONTENT ACCESS</p>
<h3 id="private-content-notice-title"></h3>
<p>访</p>
<button disabled={acknowledging} onClick={() => void acknowledge()} type="button">{acknowledging ? "确认中" : "确认并进入记录"}</button>
</section>
) : null}
{session?.notice_acknowledged ? (
<section aria-label="生成记录元数据" className="admin-generations-table-wrap">
<table><thead><tr><th></th><th></th><th> / </th><th></th><th> / </th><th></th><th></th></tr></thead><tbody>
{records.map((record) => <tr key={record.generation_id}>
<td><code>{compactId(record.generation_id)}</code></td>
<td><code>{compactId(record.owner_ref)}</code></td>
<td>{record.model_id}<br /><small>{record.ratio}</small></td>
<td><span className={`admin-generations-status is-${record.status}`}>{statusLabel(record.status)}</span>{record.error_category ? <small>{record.error_category}</small> : null}</td>
<td><time dateTime={record.created_at}>{formatTime(record.created_at)}</time><br /><small>{formatTime(record.completed_at)}</small></td>
<td>{record.confirmed_credit_cost} / {record.final_credit_state ?? "冻结"}</td>
<td className="admin-generations-actions"><button onClick={() => void openPrompt(record.generation_id)} type="button"></button><button disabled={record.status !== "succeeded"} onClick={() => void openImage(record.generation_id)} type="button"></button></td>
</tr>)}
</tbody></table>
{!records.length && !loading ? <p className="admin-generations-empty"></p> : null}
</section>
) : null}
{openedPrompt ? <section aria-label="已审计的完整提示词" className="admin-generations-opened"><header><h3></h3><button onClick={() => setOpenedPrompt(undefined)} type="button"></button></header><p><code>{compactId(openedPrompt.generation_id)}</code></p><pre>{openedPrompt.prompt}</pre></section> : null}
{openedImage ? <section aria-label="已审计的生成图片" className="admin-generations-opened"><header><h3></h3><button onClick={() => { URL.revokeObjectURL(openedImage.url); setOpenedImage(undefined); }} type="button"></button></header><img alt="已记录审计的生成图片" src={openedImage.url} /></section> : null}
</main>
);
}
+31 -47
View File
@@ -1,9 +1,18 @@
// Generated from openapi/openapi.json. Do not edit by hand. // Generated from openapi/openapi.json. Do not edit by hand.
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"; import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminOverviewResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, AdminGenerationListResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, PrivateContentPromptResponse, 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";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
export async function ackPrivateContentNotice(body: PrivateContentNoticeAckRequest, options: ClientOptions = {}): Promise<PrivateContentNoticeAckResponse> {
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/private-content-notice/ack`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<PrivateContentNoticeAckResponse>;
}
export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, options: ClientOptions = {}): Promise<CreditAdjustmentResponse> { export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, options: ClientOptions = {}): Promise<CreditAdjustmentResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
@@ -13,23 +22,6 @@ 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);
@@ -111,13 +103,6 @@ export async function getAdminOverview(options: ClientOptions = {}): Promise<Adm
return response.json() as Promise<AdminOverviewResponse>; return response.json() as Promise<AdminOverviewResponse>;
} }
export async function getAdminServices(options: ClientOptions = {}): Promise<AdminServicesResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/services`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminServicesResponse>;
}
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> { export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} }); const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} });
@@ -199,6 +184,13 @@ export async function getUserSession(options: ClientOptions = {}): Promise<UserS
return response.json() as Promise<UserSessionResponse>; return response.json() as Promise<UserSessionResponse>;
} }
export async function listAdminGenerations(options: ClientOptions = {}): Promise<AdminGenerationListResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/generations`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminGenerationListResponse>;
}
export async function listProjects(options: ClientOptions = {}): Promise<ProjectListResponse> { export async function listProjects(options: ClientOptions = {}): Promise<ProjectListResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects`, { method: "GET", headers: options.headers ?? {} }); const response = await request(`${options.baseUrl ?? ""}/api/v1/projects`, { method: "GET", headers: options.headers ?? {} });
@@ -220,6 +212,20 @@ export async function logoutUser(options: ClientOptions = {}): Promise<LogoutRes
return response.json() as Promise<LogoutResponse>; return response.json() as Promise<LogoutResponse>;
} }
export async function openAdminGenerationImage(options: ClientOptions = {}): Promise<Blob> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content/generations/{generationId}/image`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.blob() as Promise<Blob>;
}
export async function openAdminGenerationPrompt(options: ClientOptions = {}): Promise<PrivateContentPromptResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/private-content/generations/{generationId}/prompt`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<PrivateContentPromptResponse>;
}
export async function purgeProject(options: ClientOptions = {}): Promise<ProjectPurgeResponse> { export async function purgeProject(options: ClientOptions = {}): Promise<ProjectPurgeResponse> {
const request = options.fetch ?? globalThis.fetch; const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/purge`, { method: "POST", headers: options.headers ?? {} }); const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/purge`, { method: "POST", headers: options.headers ?? {} });
@@ -236,19 +242,6 @@ 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);
@@ -358,12 +351,3 @@ 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>;
}
+42 -39
View File
@@ -60,6 +60,27 @@ export type AdminCreditParams = {
"userId": string; "userId": string;
}; };
export type AdminGenerationListResponse = {
"generated_at": string;
"items": Array<AdminGenerationRecord>;
};
export type AdminGenerationRecord = {
"completed_at": string | null;
"confirmed_credit_cost": number;
"created_at": string;
"duration_ms": number | null;
"error_category": "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable" | null;
"final_credit_state": "committed" | "released" | null;
"generation_id": string;
"model_id": string;
"owner_ref": string;
"project_id": string;
"ratio": "3:4" | "1:1" | "4:3" | "9:16";
"reserved_credits": number;
"status": "queued" | "running" | "succeeded" | "failed" | "rejected";
};
export type AdminLoginCompleteRequest = { export type AdminLoginCompleteRequest = {
"registration_id": string; "registration_id": string;
"verification_code": string; "verification_code": string;
@@ -117,34 +138,13 @@ export type AdminOverviewResponse = {
}; };
}; };
export type AdminServiceHealthCheckRequest = {
"available": boolean;
"reason"?: string;
};
export type AdminServiceLimitRequest = {
"hard_limit": number;
"period_type": ExternalServicePeriodType;
};
export type AdminServiceParams = {
"service_id": ExternalServiceId;
};
export type AdminServiceRecoveryRequest = {
"check_id": string;
};
export type AdminServicesResponse = {
"services": Array<ExternalServiceUsage>;
};
export type AdminSessionResponse = { export type AdminSessionResponse = {
"acknowledged_private_content_notice_version": string | null; "acknowledged_private_content_notice_version": string | null;
"admin": AdminAuthenticatedUser; "admin": AdminAuthenticatedUser;
"audience": "admin"; "audience": "admin";
"authenticated": true; "authenticated": true;
"csrf_token": string; "csrf_token": string;
"current_private_content_notice_message_key"?: string;
"current_private_content_notice_version": string | null; "current_private_content_notice_version": string | null;
"expires_at": string; "expires_at": string;
"notice_acknowledged": boolean; "notice_acknowledged": boolean;
@@ -378,23 +378,6 @@ 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>;
}; };
@@ -621,6 +604,26 @@ export type ModelRuntimeSseEvent = {
"runtime_availability_version": number; "runtime_availability_version": number;
}; };
export type PrivateContentGenerationParams = {
"generationId": string;
};
export type PrivateContentNoticeAckRequest = {
"expected_notice_version": string;
};
export type PrivateContentNoticeAckResponse = {
"acknowledged_at": string;
"notice_version": string;
"status": "acknowledged";
};
export type PrivateContentPromptResponse = {
"content_type": "prompt";
"generation_id": string;
"prompt": string;
};
export type ProjectDetailResponse = { export type ProjectDetailResponse = {
"canvas_state": CanvasState; "canvas_state": CanvasState;
"created_at": string; "created_at": string;
+2 -1
View File
@@ -7,6 +7,7 @@ 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 { AdminGenerationsPage } from "./admin-generations.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";
@@ -41,7 +42,7 @@ function renderAuthenticationEntry() {
"/admin": { content: <AdminOverviewPage />, title: "运营总览" }, "/admin": { content: <AdminOverviewPage />, title: "运营总览" },
"/admin/assets": { content: <AdminPlaceholderPage title="素材" />, title: "素材" }, "/admin/assets": { content: <AdminPlaceholderPage title="素材" />, title: "素材" },
"/admin/audit": { content: <AdminPlaceholderPage title="审计" />, title: "审计" }, "/admin/audit": { content: <AdminPlaceholderPage title="审计" />, title: "审计" },
"/admin/generations": { content: <AdminPlaceholderPage title="生成记录" />, title: "生成记录" }, "/admin/generations": { content: <AdminGenerationsPage />, title: "生成记录" },
"/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: "内部预览" },
+496 -517
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -3,6 +3,72 @@ 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$"; 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$";
const modelIdPattern = "^[a-z0-9][a-z0-9.-]+$"; const modelIdPattern = "^[a-z0-9][a-z0-9.-]+$";
const safeReferencePattern = "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$"; const safeReferencePattern = "^[A-Za-z0-9][A-Za-z0-9:._-]{0,159}$";
const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$";
export const AdminGenerationRecordSchema = Type.Object(
{
generation_id: Type.String({ pattern: uuidPattern }),
owner_ref: Type.String({ pattern: uuidPattern }),
project_id: Type.String({ pattern: uuidPattern }),
model_id: Type.String({ maxLength: 80, pattern: modelIdPattern }),
ratio: Type.Union([Type.Literal("3:4"), Type.Literal("1:1"), Type.Literal("4:3"), Type.Literal("9:16")]),
status: Type.Union([Type.Literal("queued"), Type.Literal("running"), Type.Literal("succeeded"), Type.Literal("failed"), Type.Literal("rejected")]),
created_at: Type.String({ pattern: isoTimestampPattern }),
completed_at: Type.Union([Type.String({ pattern: isoTimestampPattern }), Type.Null()]),
duration_ms: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]),
confirmed_credit_cost: Type.Integer({ minimum: 0 }),
reserved_credits: Type.Integer({ minimum: 0 }),
final_credit_state: Type.Union([Type.Literal("committed"), Type.Literal("released"), Type.Null()]),
error_category: Type.Union([
Type.Literal("upstream_timeout"), Type.Literal("upstream_failed"), Type.Literal("safety_rejected"),
Type.Literal("model_disabled"), Type.Literal("gateway_balance_insufficient"), Type.Literal("gateway_contract_invalid"),
Type.Literal("reference_invalid"), Type.Literal("unknown_retryable"), Type.Literal("unknown_non_retryable"), Type.Null(),
]),
},
{ additionalProperties: false, $id: "AdminGenerationRecord" },
);
export const AdminGenerationListResponseSchema = Type.Object(
{
generated_at: Type.String({ pattern: isoTimestampPattern }),
items: Type.Array(Type.Ref(AdminGenerationRecordSchema), { maxItems: 100 }),
},
{ additionalProperties: false, $id: "AdminGenerationListResponse" },
);
export const PrivateContentNoticeAckRequestSchema = Type.Object(
{ expected_notice_version: Type.String({ minLength: 1, maxLength: 80, pattern: "^[A-Za-z0-9_.:-]+$" }) },
{ additionalProperties: false, $id: "PrivateContentNoticeAckRequest" },
);
export const PrivateContentNoticeAckResponseSchema = Type.Object(
{
notice_version: Type.String({ minLength: 1, maxLength: 80, pattern: "^[A-Za-z0-9_.:-]+$" }),
acknowledged_at: Type.String({ pattern: isoTimestampPattern }),
status: Type.Literal("acknowledged"),
},
{ additionalProperties: false, $id: "PrivateContentNoticeAckResponse" },
);
export const PrivateContentPromptResponseSchema = Type.Object(
{
generation_id: Type.String({ pattern: uuidPattern }),
content_type: Type.Literal("prompt"),
prompt: Type.String({ minLength: 1, maxLength: 4000 }),
},
{ additionalProperties: false, $id: "PrivateContentPromptResponse" },
);
export const PrivateContentGenerationParamsSchema = Type.Object(
{ generationId: Type.String({ pattern: uuidPattern }) },
{ additionalProperties: false, $id: "PrivateContentGenerationParams" },
);
export type AdminGenerationRecord = Static<typeof AdminGenerationRecordSchema>;
export type AdminGenerationListResponse = Static<typeof AdminGenerationListResponseSchema>;
export type PrivateContentNoticeAckRequest = Static<typeof PrivateContentNoticeAckRequestSchema>;
export type PrivateContentNoticeAckResponse = Static<typeof PrivateContentNoticeAckResponseSchema>;
export type PrivateContentPromptResponse = Static<typeof PrivateContentPromptResponseSchema>;
export const AdminOverviewResponseSchema = Type.Object( export const AdminOverviewResponseSchema = Type.Object(
{ {
+1
View File
@@ -149,6 +149,7 @@ export const AdminSessionResponseSchema = Type.Object(
audience: Type.Literal("admin"), audience: Type.Literal("admin"),
authenticated: Type.Literal(true), authenticated: Type.Literal(true),
csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }), csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
current_private_content_notice_message_key: Type.Optional(Type.String({ maxLength: 120, pattern: "^[A-Za-z0-9_.-]+$" })),
current_private_content_notice_version: Type.Union([Type.String(), Type.Null()]), current_private_content_notice_version: Type.Union([Type.String(), Type.Null()]),
expires_at: Type.String({ pattern: isoTimestampPattern }), expires_at: Type.String({ pattern: isoTimestampPattern }),
notice_acknowledged: Type.Boolean(), notice_acknowledged: Type.Boolean(),
-1
View File
@@ -1,7 +1,6 @@
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";
-62
View File
@@ -1,62 +0,0 @@
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>;
+105
View File
@@ -0,0 +1,105 @@
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 { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
const roots: string[] = [];
const services: RegistrationService[] = [];
const now = Date.parse("2026-08-04T09:30:00.000Z");
const headers = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
function harness() {
const root = mkdtempSync(join(tmpdir(), "dada-wp6-02-api-"));
roots.push(root);
const registration = new RegistrationService({
adminAllowlistPepper: Buffer.alloc(32, 0xc1),
challengePepper: Buffer.alloc(32, 0xc2),
clock: () => now,
currentPrivacyNoticeVersion: "p0a-private-content-v1",
databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0xc3),
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0xc4),
});
services.push(registration);
const adminId = randomUUID();
const ownerId = randomUUID();
const projectId = randomUUID();
const generationId = 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, ?, ?), (?, ?, 'user', 'active', 1, ?, ?)
`).run(adminId, `admin-${adminId}@example.invalid`, randomUUID(), now, ownerId, `user-${ownerId}@example.invalid`, randomUUID(), now);
registration.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(adminId);
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Admin', '@admin')").run(adminId);
registration.database.exec(`
CREATE TABLE generation_jobs (
generation_id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, project_id TEXT NOT NULL,
prompt TEXT NOT NULL, ratio TEXT NOT NULL, status TEXT NOT NULL, model_id TEXT NOT NULL,
model_config_version INTEGER NOT NULL, confirmed_credit_cost INTEGER NOT NULL,
reserved_credits INTEGER NOT NULL, final_credit_state TEXT, error_category TEXT,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, submission_ready INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE project_images (image_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, generation_id TEXT NOT NULL, created_at INTEGER NOT NULL);
`);
registration.database.prepare(`
INSERT INTO generation_jobs (
generation_id, owner_id, project_id, prompt, ratio, status, model_id, model_config_version,
confirmed_credit_cost, reserved_credits, final_credit_state, error_category, created_at, updated_at, submission_ready
) VALUES (?, ?, ?, ?, '1:1', 'succeeded', 'demo.model', 1, 2, 0, 'committed', NULL, ?, ?, 1)
`).run(generationId, ownerId, projectId, "secret prompt must never be listed", now - 1000, now);
return { registration, adminId, generationId, adminSession: registration.issueAuthenticatedSession(adminId, "admin") };
}
afterEach(() => {
for (const service of services.splice(0)) service.close();
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("TDD-WP6-PRIV-001/TDD-WP6-PRIV-002", () => {
it("persists notice acknowledgement, returns metadata only, and audits every prompt open", async () => {
const fixture = harness();
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration: fixture.registration });
const cookie = `dada_admin_session=${fixture.adminSession.sessionToken}`;
const denied = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/admin/generations" });
expect(denied.statusCode).toBe(428);
expect(denied.json()).toMatchObject({ error: { code: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED", details: { latest_version: "p0a-private-content-v1" } } });
expect(JSON.stringify(denied.json())).not.toContain("secret prompt");
const csrf = fixture.registration.issueAdminCsrfToken(fixture.adminSession.sessionToken);
const ack = await app.inject({
headers: { ...headers, cookie, "x-csrf-token": csrf, "idempotency-key": "wp6-02-ack-000000000000000000000000000000" },
method: "POST", payload: { expected_notice_version: "p0a-private-content-v1" }, url: "/api/v1/admin/private-content-notice/ack",
});
expect(ack.statusCode).toBe(200);
expect(ack.json()).toMatchObject({ notice_version: "p0a-private-content-v1", status: "acknowledged" });
const stale = await app.inject({
headers: { ...headers, cookie, "x-csrf-token": fixture.registration.issueAdminCsrfToken(fixture.adminSession.sessionToken), "idempotency-key": "wp6-02-ack-stale-000000000000000000000000000" },
method: "POST", payload: { expected_notice_version: "old-private-content-v0" }, url: "/api/v1/admin/private-content-notice/ack",
});
expect(stale.statusCode).toBe(428);
expect(stale.json()).toMatchObject({ error: { code: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED", details: { latest_version: "p0a-private-content-v1" } } });
const list = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: "/api/v1/admin/generations" });
expect(list.statusCode).toBe(200);
expect(list.json().items[0]).toMatchObject({ generation_id: fixture.generationId, owner_ref: expect.any(String), status: "succeeded" });
expect(JSON.stringify(list.json())).not.toContain("secret prompt");
const opened = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: `/api/v1/admin/private-content/generations/${fixture.generationId}/prompt` });
expect(opened.statusCode).toBe(200);
expect(opened.json()).toEqual({ content_type: "prompt", generation_id: fixture.generationId, prompt: "secret prompt must never be listed" });
expect((fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get() as { count: number }).count).toBe(1);
const reopened = await app.inject({ headers: { ...headers, cookie }, method: "GET", url: `/api/v1/admin/private-content/generations/${fixture.generationId}/prompt` });
expect(reopened.statusCode).toBe(200);
expect((fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM private_content_access_logs").get() as { count: number }).count).toBe(2);
await app.close();
});
});
-157
View File
@@ -1,157 +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 { 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();
});
});