merge: integrate WP6-03 dependency for TASK-WP6-04

# Conflicts:
#	apps/api/src/app.ts
#	apps/web/src/generated/api/sdk.gen.ts
#	openapi/openapi.json
This commit is contained in:
suyx
2026-08-04 11:05:14 +08:00
9 changed files with 1572 additions and 1 deletions
+171
View File
@@ -13,6 +13,15 @@ import {
AdminGenerationRecordSchema,
AdminGenerationListResponseSchema,
AdminOverviewResponseSchema,
AdminServicesResponseSchema,
AdminServiceHealthCheckRequestSchema,
AdminServiceLimitRequestSchema,
AdminServiceParamsSchema,
AdminServiceRecoveryRequestSchema,
ExternalServiceIdSchema,
ExternalServicePeriodTypeSchema,
ExternalServiceStatusSchema,
ExternalServiceUsageSchema,
AdminCreditParamsSchema,
AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema,
@@ -185,6 +194,7 @@ import type { RegistrationService } from "./registration.js";
import type { AssetPreviewGrantService } from "./preview-grants.js";
import type { RecentAssetService } from "./recent-assets.js";
import type { AmapAdapter } from "./amap-adapter.js";
import { ExternalServiceUsageError, type ExternalServiceUsage } from "./external-service-usage.js";
import { ModelConfigurationError } from "./model-configuration.js";
import type { ModelConfigurationService } from "./model-configuration.js";
import { StickerReleaseError } from "./sticker-release-errors.js";
@@ -237,6 +247,7 @@ export interface CreateAppOptions {
privateContent?: PrivateContentService;
registration?: RegistrationService;
stickers?: StickerReleaseService;
serviceUsage?: ExternalServiceUsage;
}
const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate");
@@ -738,6 +749,15 @@ export async function createApp(options: CreateAppOptions = {}) {
PrivateContentPromptResponseSchema,
PrivateContentGenerationParamsSchema,
AdminOverviewResponseSchema,
AdminServicesResponseSchema,
AdminServiceHealthCheckRequestSchema,
AdminServiceLimitRequestSchema,
AdminServiceParamsSchema,
AdminServiceRecoveryRequestSchema,
ExternalServiceIdSchema,
ExternalServicePeriodTypeSchema,
ExternalServiceStatusSchema,
ExternalServiceUsageSchema,
CreditSummarySchema,
CreditEntryTypeSchema,
CreditEntryStatusSchema,
@@ -1503,10 +1523,16 @@ export async function createApp(options: CreateAppOptions = {}) {
if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
const usage = options.serviceUsage ?? options.registration.serviceUsage;
usage.claimAmap();
const result = await options.amap.reverseGeocode(request.body as ReverseGeocodeRequest);
return { formatted_value: result.formattedValue, service_mode: result.serviceMode, status: "resolved" as const };
} catch (error) {
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
if (error instanceof ExternalServiceUsageError) return reply.code(503).send(null);
try {
(options.serviceUsage ?? options.registration.serviceUsage).markProviderFailure({ serviceId: "amap_web_service", reason: "provider_unavailable" });
} catch { /* preserve the provider failure response */ }
return reply.code(503).send(null);
}
},
@@ -1678,6 +1704,151 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.get(
"/api/v1/admin/services",
{
schema: {
operationId: "getAdminServices",
response: { 200: Type.Ref(AdminServicesResponseSchema), 401: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) },
tags: ["Admin Services"],
},
},
async (request, reply) => {
const registration = options.registration;
const usage = options.serviceUsage ?? registration?.serviceUsage;
if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
const session = token ? registration.readAdminSession(token) : undefined;
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
return {
services: usage.readCurrent().map((row) => ({
hard_limit: row.hardLimit,
pause_reason: row.pauseReason,
period_start: new Date(row.periodStart).toISOString(),
period_type: row.periodType,
service_id: row.serviceId,
service_status: row.status,
updated_at: new Date(row.updatedAt).toISOString(),
used_count: row.usedCount,
})),
};
},
);
app.post(
"/api/v1/admin/services/:service_id/health-check",
{
attachValidation: true,
schema: {
params: Type.Ref(AdminServiceParamsSchema),
body: Type.Ref(AdminServiceHealthCheckRequestSchema),
headers: Type.Ref(ModelConfigUpdateHeadersSchema),
operationId: "checkAdminServiceHealth",
response: { 200: Type.Object({ check_id: Type.String(), available: Type.Boolean(), checked_at: Type.String() }, { additionalProperties: false }), 400: Type.Ref(ErrorEnvelopeSchema), 401: Type.Ref(ErrorEnvelopeSchema), 403: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) },
tags: ["Admin Services"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(createErrorEnvelope({ code: "REGISTRATION_REQUEST_INVALID", correlationId: request.id }));
const registration = options.registration;
const usage = options.serviceUsage ?? registration?.serviceUsage;
if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const headers = request.headers as { "x-csrf-token": string };
registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const body = request.body as { available: boolean; reason?: string };
const params = request.params as { service_id: "resend_email" | "amap_web_service" };
const check = usage.recordHealthCheck({ serviceId: params.service_id, available: body.available, ...(body.reason ? { reason: body.reason } : {}) });
return { check_id: check.checkId, available: check.available, checked_at: new Date(check.checkedAt).toISOString() };
} catch (error) {
if (error instanceof RegistrationError) return reply.code(error.httpStatus).send(createErrorEnvelope({ code: error.code, correlationId: request.id }));
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.patch(
"/api/v1/admin/services/:service_id/limits",
{
attachValidation: true,
schema: {
params: Type.Ref(AdminServiceParamsSchema),
body: Type.Ref(AdminServiceLimitRequestSchema),
headers: Type.Ref(ModelConfigUpdateHeadersSchema),
operationId: "updateAdminServiceHardLimit",
response: { 200: Type.Ref(AdminServicesResponseSchema), 400: Type.Ref(ErrorEnvelopeSchema), 401: Type.Ref(ErrorEnvelopeSchema), 403: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) },
tags: ["Admin Services"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(createErrorEnvelope({ code: "REGISTRATION_REQUEST_INVALID", correlationId: request.id }));
const registration = options.registration;
const usage = options.serviceUsage ?? registration?.serviceUsage;
if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const headers = request.headers as { "x-csrf-token": string };
const admin = registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const body = request.body as { hard_limit: number; period_type: "daily" | "monthly" };
const params = request.params as { service_id: "resend_email" | "amap_web_service" };
usage.setHardLimit({ actorId: admin.userId, hardLimit: body.hard_limit, periodType: body.period_type, serviceId: params.service_id });
return {
services: usage.readCurrent().map((row) => ({
hard_limit: row.hardLimit,
pause_reason: row.pauseReason,
period_start: new Date(row.periodStart).toISOString(),
period_type: row.periodType,
service_id: row.serviceId,
service_status: row.status,
updated_at: new Date(row.updatedAt).toISOString(),
used_count: row.usedCount,
})),
};
} catch (error) {
if (error instanceof RegistrationError) return reply.code(error.httpStatus).send(createErrorEnvelope({ code: error.code, correlationId: request.id }));
if (error instanceof ExternalServiceUsageError) return reply.code(409).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.post(
"/api/v1/admin/services/:service_id/recover",
{
attachValidation: true,
schema: {
params: Type.Ref(AdminServiceParamsSchema),
body: Type.Ref(AdminServiceRecoveryRequestSchema),
headers: Type.Ref(ModelConfigUpdateHeadersSchema),
operationId: "recoverAdminService",
response: { 200: Type.Object({ status: Type.Literal("active") }, { additionalProperties: false }), 400: Type.Ref(ErrorEnvelopeSchema), 401: Type.Ref(ErrorEnvelopeSchema), 403: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema) },
tags: ["Admin Services"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(createErrorEnvelope({ code: "REGISTRATION_REQUEST_INVALID", correlationId: request.id }));
const registration = options.registration;
const usage = options.serviceUsage ?? registration?.serviceUsage;
if (!registration || !usage) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const headers = request.headers as { "x-csrf-token": string };
const admin = registration.authorizeAdminMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
const body = request.body as { check_id: string };
const params = request.params as { service_id: "resend_email" | "amap_web_service" };
return usage.recover({ actorId: admin.userId, checkId: body.check_id, serviceId: params.service_id });
} catch (error) {
if (error instanceof RegistrationError) return reply.code(error.httpStatus).send(createErrorEnvelope({ code: error.code, correlationId: request.id }));
if (error instanceof ExternalServiceUsageError) return reply.code(409).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
},
);
app.post(
"/api/v1/auth/login/send",
{
+459
View File
@@ -0,0 +1,459 @@
import { randomUUID } from "node:crypto";
import type BetterSqlite3 from "better-sqlite3";
import { serializeAuditSummary } from "./audit-policy.js";
export type ExternalServiceId = "resend_email" | "amap_web_service";
export type ExternalServicePeriodType = "daily" | "monthly";
export type ExternalServiceStatus = "active" | "paused_quota" | "paused_provider" | "disabled";
const retentionMilliseconds = 180 * 24 * 60 * 60 * 1_000;
const recoveryCheckLifetimeMilliseconds = 15 * 60 * 1_000;
const maximumHardLimits: Record<ExternalServiceId, Partial<Record<ExternalServicePeriodType, number>>> = {
resend_email: { daily: 80, monthly: 2_400 },
amap_web_service: { monthly: 1_000 },
};
export interface ExternalServiceUsageRow {
serviceId: ExternalServiceId;
periodType: ExternalServicePeriodType;
periodStart: number;
hardLimit: number;
usedCount: number;
status: ExternalServiceStatus;
pauseReason: string | null;
updatedAt: number;
}
export class ExternalServiceUsageError extends Error {
constructor(
readonly code:
| "service_paused_quota"
| "service_paused_provider"
| "service_disabled"
| "hard_limit_increase_forbidden"
| "hard_limit_invalid"
| "health_check_required"
| "quota_exhausted"
| "service_not_found",
message = code,
) {
super(message);
this.name = "ExternalServiceUsageError";
}
}
interface ExternalServiceUsageOptions {
clock?: () => number;
database: BetterSqlite3.Database;
}
interface RawUsageRow {
service_id: ExternalServiceId;
period_type: ExternalServicePeriodType;
period_start: number;
hard_limit: number;
used_count: number;
service_status: ExternalServiceStatus;
pause_reason: string | null;
updated_at: number;
}
function periodStart(periodType: ExternalServicePeriodType, now: number) {
const date = new Date(now);
if (periodType === "daily") return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1);
}
function requiredPeriods(serviceId: ExternalServiceId): ExternalServicePeriodType[] {
return serviceId === "resend_email" ? ["daily", "monthly"] : ["monthly"];
}
function toPublic(row: RawUsageRow): ExternalServiceUsageRow {
return {
hardLimit: row.hard_limit,
pauseReason: row.pause_reason,
periodStart: row.period_start,
periodType: row.period_type,
serviceId: row.service_id,
status: row.service_status,
updatedAt: row.updated_at,
usedCount: row.used_count,
};
}
export class ExternalServiceUsage {
readonly database: BetterSqlite3.Database;
readonly clock: () => number;
constructor(options: ExternalServiceUsageOptions) {
this.database = options.database;
this.clock = options.clock ?? Date.now;
this.ensureSchema();
this.runImmediate(() => {
this.ensureCurrentRows(this.clock(), "resend_email");
this.ensureCurrentRows(this.clock(), "amap_web_service");
});
}
claimResend(now = this.clock()) {
return this.runImmediate(() => this.claimWithinTransaction("resend_email", now));
}
claimResendWithinTransaction(now = this.clock()) {
return this.claimWithinTransaction("resend_email", now);
}
claimAmap(now = this.clock()) {
return this.runImmediate(() => this.claimWithinTransaction("amap_web_service", now));
}
claimAmapWithinTransaction(now = this.clock()) {
return this.claimWithinTransaction("amap_web_service", now);
}
markProviderFailure(input: { serviceId: ExternalServiceId; reason: string; now?: number }) {
return this.runImmediate(() => this.markProviderFailureWithinTransaction(input));
}
markProviderFailureWithinTransaction(input: { serviceId: ExternalServiceId; reason: string; now?: number }) {
const now = input.now ?? this.clock();
const rows = this.ensureCurrentRows(now, input.serviceId);
const reason = normalizeReason(input.reason);
for (const row of rows) {
if (row.service_status === "disabled") continue;
this.database.prepare(`
UPDATE external_service_usage
SET service_status = 'paused_provider', pause_reason = ?, updated_at = ?
WHERE service_id = ? AND period_type = ? AND period_start = ?
`).run(reason, now, row.service_id, row.period_type, row.period_start);
}
this.recordAudit({
actorRef: "external_service_runtime",
actorType: "system",
afterSummary: { pause_reason: reason, status: "paused_provider" },
beforeSummary: { status: rows[0]?.service_status ?? "active" },
operationType: "service_provider_pause",
result: "succeeded",
targetRef: input.serviceId,
targetType: "external_service",
}, now);
return this.read(input.serviceId);
}
recordHealthCheck(input: { serviceId: ExternalServiceId; available: boolean; reason?: string; now?: number }) {
const now = input.now ?? this.clock();
const checkId = randomUUID();
const currentPeriod = periodStart(requiredPeriods(input.serviceId)[0]!, now);
const result = this.runImmediate(() => {
this.database.prepare(`
INSERT INTO service_recovery_checks (
check_id, service_name, target_ref, status, checked_at, expires_at, details_json,
service_id, period_start, available, check_reason
) VALUES (?, 'external_service', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
checkId,
input.serviceId,
input.available ? "passed" : "failed",
now,
now + recoveryCheckLifetimeMilliseconds,
JSON.stringify({ non_sensitive: true }),
input.serviceId,
currentPeriod,
input.available ? 1 : 0,
input.reason ? normalizeReason(input.reason) : null,
);
return { checkId, available: input.available, checkedAt: now };
});
return result;
}
recover(input: { serviceId: ExternalServiceId; actorId: string; checkId: string; now?: number }) {
const now = input.now ?? this.clock();
const result = this.runImmediate(() => {
const check = this.database.prepare(`
SELECT check_id, period_start, available, expires_at
FROM service_recovery_checks
WHERE check_id = ? AND service_id = ? AND service_name = 'external_service'
`).get(input.checkId, input.serviceId) as { available: number; check_id: string; expires_at: number; period_start: number } | undefined;
const currentPeriod = periodStart(requiredPeriods(input.serviceId)[0]!, now);
if (!check?.available || check.period_start !== currentPeriod || check.expires_at <= now) {
this.recordAudit({
actorRef: input.actorId,
actorType: "super_admin",
afterSummary: { reason: "health_check_required" },
beforeSummary: null,
operationType: "service_recovery",
result: "failed",
targetRef: input.serviceId,
targetType: "external_service",
}, now);
return { error: new ExternalServiceUsageError("health_check_required") };
}
const rows = this.ensureCurrentRows(now, input.serviceId);
if (rows.some((row) => row.used_count >= row.hard_limit)) {
this.recordAudit({
actorRef: input.actorId,
actorType: "super_admin",
afterSummary: { reason: "quota_exhausted" },
beforeSummary: { status: rows[0]?.service_status ?? "paused_quota" },
operationType: "service_recovery",
result: "failed",
targetRef: input.serviceId,
targetType: "external_service",
}, now);
return { error: new ExternalServiceUsageError("quota_exhausted") };
}
for (const row of rows) {
this.database.prepare(`
UPDATE external_service_usage
SET service_status = 'active', pause_reason = NULL, updated_at = ?
WHERE service_id = ? AND period_type = ? AND period_start = ?
`).run(now, row.service_id, row.period_type, row.period_start);
}
this.recordAudit({
actorRef: input.actorId,
actorType: "super_admin",
afterSummary: { check_id: input.checkId, status: "active" },
beforeSummary: { status: rows[0]?.service_status ?? "paused_provider" },
operationType: "service_recovery",
result: "succeeded",
targetRef: input.serviceId,
targetType: "external_service",
}, now);
return { status: "active" as const };
});
if ("error" in result && result.error) throw result.error;
return result;
}
setHardLimit(input: {
serviceId: ExternalServiceId;
periodType: ExternalServicePeriodType;
hardLimit: number;
actorId: string;
now?: number;
}) {
const now = input.now ?? this.clock();
const result = this.runImmediate(() => {
const maximum = maximumHardLimits[input.serviceId][input.periodType];
const current = this.ensureCurrentRows(now, input.serviceId).find((row) => row.period_type === input.periodType);
if (current && input.hardLimit > current.hard_limit) {
this.recordAudit({
actorRef: input.actorId,
actorType: "super_admin",
afterSummary: { reason: "hard_limit_increase_forbidden" },
beforeSummary: { hard_limit: current.hard_limit },
operationType: "service_hard_limit_update",
result: "failed",
targetRef: `${input.serviceId}:${input.periodType}`,
targetType: "external_service_limit",
}, now);
return { error: new ExternalServiceUsageError("hard_limit_increase_forbidden") };
}
const valid = maximum !== undefined && Number.isSafeInteger(input.hardLimit) && input.hardLimit >= 1 && input.hardLimit <= maximum;
if (!valid || !current) {
this.recordAudit({
actorRef: input.actorId,
actorType: "super_admin",
afterSummary: { reason: "hard_limit_invalid" },
beforeSummary: current ? { hard_limit: current.hard_limit } : null,
operationType: "service_hard_limit_update",
result: "failed",
targetRef: `${input.serviceId}:${input.periodType}`,
targetType: "external_service_limit",
}, now);
return { error: new ExternalServiceUsageError("hard_limit_invalid") };
}
this.database.prepare(`
UPDATE external_service_usage
SET hard_limit = ?, service_status = CASE
WHEN used_count >= ? THEN 'paused_quota'
ELSE service_status
END, pause_reason = CASE
WHEN used_count >= ? THEN 'hard_limit_reached'
ELSE pause_reason
END, updated_at = ?
WHERE service_id = ? AND period_type = ? AND period_start = ?
`).run(input.hardLimit, input.hardLimit, input.hardLimit, now, current.service_id, current.period_type, current.period_start);
this.recordAudit({
actorRef: input.actorId,
actorType: "super_admin",
afterSummary: { hard_limit: input.hardLimit },
beforeSummary: { hard_limit: current.hard_limit },
operationType: "service_hard_limit_update",
result: "succeeded",
targetRef: `${input.serviceId}:${input.periodType}`,
targetType: "external_service_limit",
}, now);
return this.read(input.serviceId);
});
if ("error" in result && result.error) throw result.error;
return result;
}
read(serviceId?: ExternalServiceId) {
const rows = (serviceId
? this.database.prepare("SELECT * FROM external_service_usage WHERE service_id = ? ORDER BY period_type").all(serviceId)
: this.database.prepare("SELECT * FROM external_service_usage ORDER BY service_id, period_type").all()) as RawUsageRow[];
return rows.map(toPublic);
}
readCurrent() {
const now = this.clock();
return this.read().filter((row) => row.periodStart === periodStart(row.periodType, now));
}
readStatus(serviceId: ExternalServiceId) {
const rows = this.read(serviceId).filter((row) => row.periodStart >= periodStart(row.periodType, this.clock()));
const status = rows.some((row) => row.status === "disabled")
? "disabled"
: rows.some((row) => row.status === "paused_provider")
? "paused_provider"
: rows.some((row) => row.status === "paused_quota")
? "paused_quota"
: "active";
return { serviceId, status, rows } as const;
}
private claimWithinTransaction(serviceId: ExternalServiceId, now: number) {
const rows = this.ensureCurrentRows(now, serviceId);
for (const row of rows) {
if (row.service_status === "paused_quota") throw new ExternalServiceUsageError("service_paused_quota");
if (row.service_status === "paused_provider") throw new ExternalServiceUsageError("service_paused_provider");
if (row.service_status === "disabled") throw new ExternalServiceUsageError("service_disabled");
if (row.used_count >= row.hard_limit) {
this.database.prepare(`
UPDATE external_service_usage
SET service_status = 'paused_quota', pause_reason = 'hard_limit_reached', updated_at = ?
WHERE service_id = ? AND period_type = ? AND period_start = ?
`).run(now, row.service_id, row.period_type, row.period_start);
throw new ExternalServiceUsageError("service_paused_quota");
}
}
const updated = rows.map((row) => {
const usedCount = row.used_count + 1;
const status: ExternalServiceStatus = usedCount >= row.hard_limit ? "paused_quota" : "active";
this.database.prepare(`
UPDATE external_service_usage
SET used_count = ?, service_status = ?, pause_reason = CASE WHEN ? = 'active' THEN NULL ELSE 'hard_limit_reached' END, updated_at = ?
WHERE service_id = ? AND period_type = ? AND period_start = ?
`).run(usedCount, status, status, now, row.service_id, row.period_type, row.period_start);
return { periodType: row.period_type, remaining: Math.max(0, row.hard_limit - usedCount), usedCount };
});
return { allowed: true as const, serviceId, allocations: updated, remaining: Math.min(...updated.map((item) => item.remaining)) };
}
private ensureCurrentRows(now: number, serviceId: ExternalServiceId) {
const periods = requiredPeriods(serviceId);
for (const periodType of periods) {
const start = periodStart(periodType, now);
const current = this.database.prepare(`
SELECT * FROM external_service_usage WHERE service_id = ? AND period_type = ? AND period_start = ?
`).get(serviceId, periodType, start) as RawUsageRow | undefined;
if (current) continue;
const previous = this.database.prepare(`
SELECT hard_limit FROM external_service_usage
WHERE service_id = ? AND period_type = ? ORDER BY period_start DESC LIMIT 1
`).get(serviceId, periodType) as { hard_limit: number } | undefined;
const maximum = maximumHardLimits[serviceId][periodType];
if (maximum === undefined) throw new ExternalServiceUsageError("service_not_found");
this.database.prepare(`
INSERT INTO external_service_usage (
service_id, period_type, period_start, hard_limit, used_count,
service_status, pause_reason, updated_at
) VALUES (?, ?, ?, ?, 0, ?, ?, ?)
`).run(serviceId, periodType, start, previous?.hard_limit ?? maximum, previous ? "paused_quota" : "active", previous ? "period_confirmation_required" : null, now);
}
return this.database.prepare(`
SELECT * FROM external_service_usage
WHERE service_id = ? AND period_start IN (${periods.map(() => "?").join(",")})
ORDER BY period_type
`).all(serviceId, ...periods.map((period) => periodStart(period, now))) as RawUsageRow[];
}
private ensureSchema() {
this.database.exec(`
CREATE TABLE IF NOT EXISTS external_service_usage (
service_id TEXT NOT NULL CHECK (service_id IN ('resend_email', 'amap_web_service')),
period_type TEXT NOT NULL CHECK (period_type IN ('daily', 'monthly')),
period_start INTEGER NOT NULL,
hard_limit INTEGER NOT NULL CHECK (hard_limit >= 1),
used_count INTEGER NOT NULL CHECK (used_count >= 0 AND used_count <= hard_limit),
service_status TEXT NOT NULL CHECK (service_status IN ('active', 'paused_quota', 'paused_provider', 'disabled')),
pause_reason TEXT,
updated_at INTEGER NOT NULL,
PRIMARY KEY (service_id, period_type, period_start)
);
CREATE TABLE IF NOT EXISTS service_recovery_checks (
check_id TEXT PRIMARY KEY,
service_name TEXT NOT NULL DEFAULT 'external_service',
target_ref TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'passed' CHECK (status IN ('passed', 'failed')),
checked_at INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER NOT NULL DEFAULT 0,
details_json TEXT NOT NULL DEFAULT '{}',
service_id TEXT CHECK (service_id IS NULL OR service_id IN ('resend_email', 'amap_web_service')),
period_start INTEGER,
available INTEGER CHECK (available IS NULL OR available IN (0, 1)),
check_reason TEXT
);
`);
const columns = new Set((this.database.prepare("PRAGMA table_info(service_recovery_checks)").all() as Array<{ name: string }>).map((column) => column.name));
const additions: Array<[string, string]> = [
["service_name", "TEXT NOT NULL DEFAULT 'external_service'"],
["target_ref", "TEXT NOT NULL DEFAULT ''"],
["status", "TEXT NOT NULL DEFAULT 'passed'"],
["expires_at", "INTEGER NOT NULL DEFAULT 0"],
["details_json", "TEXT NOT NULL DEFAULT '{}'"],
["service_id", "TEXT"],
["period_start", "INTEGER"],
["available", "INTEGER"],
["check_reason", "TEXT"],
];
for (const [name, definition] of additions) {
if (!columns.has(name)) this.database.exec(`ALTER TABLE service_recovery_checks ADD COLUMN ${name} ${definition}`);
}
}
private runImmediate<T>(action: () => T): T {
const nested = this.database.inTransaction;
if (!nested) this.database.exec("BEGIN IMMEDIATE");
try {
const result = action();
if (!nested) this.database.exec("COMMIT");
return result;
} catch (error) {
if (!nested && this.database.inTransaction) this.database.exec("ROLLBACK");
throw error;
}
}
private recordAudit(input: {
actorRef: string;
actorType: "system" | "super_admin";
afterSummary: Record<string, unknown> | null;
beforeSummary: Record<string, unknown> | null;
operationType: string;
result: "succeeded" | "failed";
targetRef: string;
targetType: string;
}, now: number) {
this.database.prepare(`
INSERT INTO admin_operation_logs (
log_id, actor_type, actor_ref, operation_type, target_type, target_ref,
result, before_summary, after_summary, occurred_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
randomUUID(), input.actorType, input.actorRef, input.operationType, input.targetType, input.targetRef,
input.result, serializeAuditSummary(input.beforeSummary), serializeAuditSummary(input.afterSummary), now,
now + retentionMilliseconds,
);
}
}
function normalizeReason(reason: string) {
const normalized = reason.trim().toLowerCase().replace(/[^a-z0-9_.-]/g, "_").slice(0, 120);
return normalized || "provider_unavailable";
}
+11
View File
@@ -11,6 +11,7 @@ import {
serializeAuditSummary,
} from "./audit-policy.js";
import type { ResendAdapter } from "./resend-adapter.js";
import { ExternalServiceUsage } from "./external-service-usage.js";
import {
RegistrationError,
type RegistrationErrorReason,
@@ -226,6 +227,7 @@ function constantTimeTextEqual(left: string, right: string) {
export class RegistrationService {
readonly database: BetterSqlite3.Database;
readonly serviceUsage: ExternalServiceUsage;
readonly options: Required<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
private adminAllowlistHashes = new Set<string>();
private privacyPurgeActive = false;
@@ -254,6 +256,7 @@ export class RegistrationService {
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
this.database.function("dada_retention_purge_now", { deterministic: false }, () => 0);
this.migrate();
this.serviceUsage = new ExternalServiceUsage({ database: this.database, clock: this.options.clock });
}
close() {
@@ -299,6 +302,7 @@ export class RegistrationService {
if (existing) throw new RegistrationError("AUTH_ENTRY_REJECTED", "registration_login_required");
this.assertChallengeSendAllowed(email, "register", "registration", now);
this.recordRateSend(email, "registration", now);
this.serviceUsage.claimResendWithinTransaction(now);
this.database.prepare(`
INSERT INTO email_challenges (
@@ -328,6 +332,7 @@ export class RegistrationService {
try {
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "register" });
} catch {
this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now });
this.runImmediate("registration_send_compensation", () => {
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
return { outcome: "committed", value: undefined };
@@ -355,6 +360,7 @@ export class RegistrationService {
if (user.role !== "user") throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_admin_required");
this.assertChallengeSendAllowed(email, "login", clientKey, now);
this.recordRateSend(email, clientKey, now);
this.serviceUsage.claimResendWithinTransaction(now);
this.database.prepare(`
INSERT INTO email_challenges (
challenge_id, email, invite_id, code_hmac, purpose, expires_at,
@@ -382,6 +388,7 @@ export class RegistrationService {
try {
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "login" });
} catch {
this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now });
this.runImmediate("registration_send_compensation", () => {
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
return { outcome: "committed", value: undefined };
@@ -768,6 +775,7 @@ export class RegistrationService {
}
this.assertChallengeSendAllowed(email, "admin_login", clientKey, now);
this.recordRateSend(email, clientKey, now);
this.serviceUsage.claimResendWithinTransaction(now);
this.database.prepare(`
INSERT INTO email_challenges (
challenge_id, email, invite_id, code_hmac, purpose, expires_at,
@@ -795,6 +803,7 @@ export class RegistrationService {
try {
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "admin_login" });
} catch {
this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now });
this.runImmediate("registration_send_compensation", () => {
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
this.recordAdminLoginRejection("service_unavailable", now);
@@ -1097,6 +1106,7 @@ export class RegistrationService {
now + resendDelayMilliseconds,
now,
);
this.serviceUsage.claimResendWithinTransaction(now);
return {
outcome: "committed",
value: {
@@ -1116,6 +1126,7 @@ export class RegistrationService {
purpose: "account_delete",
});
} catch {
this.serviceUsage.markProviderFailure({ serviceId: "resend_email", reason: "provider_unavailable", now });
this.runImmediate("registration_send_compensation", () => {
this.database.prepare("DELETE FROM account_deletion_challenges WHERE deletion_id = ? AND consumed_at IS NULL").run(deletionId);
return { outcome: "committed", value: undefined };
+47 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand.
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";
import type { PrivateContentNoticeAckResponse, PrivateContentNoticeAckRequest, 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, AdminGenerationListResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, PrivateContentPromptResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, AdminServiceRecoveryRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest, AdminServiceLimitRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
@@ -22,6 +22,23 @@ export async function adjustAdminUserCredits(body: CreditAdjustmentRequest, opti
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> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
@@ -103,6 +120,13 @@ export async function getAdminOverview(options: ClientOptions = {}): Promise<Adm
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> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} });
@@ -242,6 +266,19 @@ export async function recordRecentAsset(body: RecentAssetRecordRequest, options:
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> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
@@ -351,3 +388,12 @@ export async function updateAccountProfile(body: AccountProfileUpdateRequest, op
if (!response.ok) throw new Error(`HTTP ${response.status}`);
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>;
}
+39
View File
@@ -138,6 +138,28 @@ export type AdminOverviewResponse = {
};
};
export type AdminServiceHealthCheckRequest = {
"available": boolean;
"reason"?: string;
};
export type AdminServiceLimitRequest = {
"hard_limit": number;
"period_type": ExternalServicePeriodType;
};
export type AdminServiceParams = {
"service_id": ExternalServiceId;
};
export type AdminServiceRecoveryRequest = {
"check_id": string;
};
export type AdminServicesResponse = {
"services": Array<ExternalServiceUsage>;
};
export type AdminSessionResponse = {
"acknowledged_private_content_notice_version": string | null;
"admin": AdminAuthenticatedUser;
@@ -378,6 +400,23 @@ export type ErrorEnvelope = {
export type ExportFormat = "jpg" | "png";
export type ExternalServiceId = "resend_email" | "amap_web_service";
export type ExternalServicePeriodType = "daily" | "monthly";
export type ExternalServiceStatus = "active" | "paused_quota" | "paused_provider" | "disabled";
export type ExternalServiceUsage = {
"hard_limit": number;
"pause_reason": string | null;
"period_start": string;
"period_type": ExternalServicePeriodType;
"service_id": ExternalServiceId;
"service_status": ExternalServiceStatus;
"updated_at": string;
"used_count": number;
};
export type FailedEmptyTrashRequest = {
"project_ids": Array<ProjectId>;
};