feat(P0-A): 整合第一版并冻结最终发布 #1
@@ -5,17 +5,28 @@ import { resolve } from "node:path";
|
||||
import {
|
||||
BootstrapResponseSchema,
|
||||
CorrelationIdSchema,
|
||||
AuthenticatedUserSchema,
|
||||
CreditSummarySchema,
|
||||
ErrorDetailsSchema,
|
||||
ErrorEnvelopeSchema,
|
||||
GenerationErrorCategorySchema,
|
||||
ModelConfigSseEventSchema,
|
||||
ModelRuntimeSseEventSchema,
|
||||
RegistrationCompleteHeadersSchema,
|
||||
RegistrationCompleteRequestSchema,
|
||||
RegistrationCompleteResponseSchema,
|
||||
RegistrationSendRequestSchema,
|
||||
RegistrationSendResponseSchema,
|
||||
SseEventSchema,
|
||||
StableEngineeringErrorCodeSchema,
|
||||
StateSseEventSchema,
|
||||
Type,
|
||||
UserSessionResponseSchema,
|
||||
createErrorEnvelope,
|
||||
isCorrelationId,
|
||||
type BootstrapResponse,
|
||||
type RegistrationCompleteRequest,
|
||||
type RegistrationSendRequest,
|
||||
} from "@dada/shared-contracts";
|
||||
import swagger from "@fastify/swagger";
|
||||
import Fastify, { type FastifyReply } from "fastify";
|
||||
@@ -36,6 +47,11 @@ import {
|
||||
import { EventHub } from "./event-hub.js";
|
||||
import type { PublicAssetResolver } from "./local-data-root.js";
|
||||
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
||||
import {
|
||||
RegistrationError,
|
||||
registrationFieldError,
|
||||
} from "./registration-errors.js";
|
||||
import type { RegistrationService } from "./registration.js";
|
||||
|
||||
const defaultBootstrap: BootstrapResponse = {
|
||||
app_version: "0.0.0",
|
||||
@@ -57,6 +73,7 @@ export interface CreateAppOptions {
|
||||
eventHub?: EventHub;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
publicAssets?: PublicAssetResolver;
|
||||
registration?: RegistrationService;
|
||||
}
|
||||
|
||||
const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate");
|
||||
@@ -86,6 +103,39 @@ function headerValue(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function cookieValue(cookieHeader: string | undefined, name: string) {
|
||||
if (!cookieHeader) return undefined;
|
||||
for (const item of cookieHeader.split(";")) {
|
||||
const separator = item.indexOf("=");
|
||||
if (separator < 0) continue;
|
||||
if (item.slice(0, separator).trim() === name) return item.slice(separator + 1).trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function registrationFailure(reply: FastifyReply, correlationId: string, error: unknown) {
|
||||
if (error instanceof RegistrationError) {
|
||||
return reply.code(error.httpStatus).send(
|
||||
createErrorEnvelope({
|
||||
code: error.code,
|
||||
correlationId,
|
||||
details: { field_errors: [registrationFieldError(error.reason)] },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
|
||||
}
|
||||
|
||||
function registrationValidationFailure(reply: FastifyReply, correlationId: string) {
|
||||
return reply.code(400).send(
|
||||
createErrorEnvelope({
|
||||
code: "REGISTRATION_REQUEST_INVALID",
|
||||
correlationId,
|
||||
details: { field_errors: [{ field: "request", message_key: "auth.registration.request_invalid" }] },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isSupportGateRequest(method: string, path: string) {
|
||||
if (method === "POST" && path === "/api/v1/support/check") return true;
|
||||
if (method !== "GET" && method !== "HEAD") return false;
|
||||
@@ -151,6 +201,14 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
StableEngineeringErrorCodeSchema,
|
||||
ErrorDetailsSchema,
|
||||
ErrorEnvelopeSchema,
|
||||
AuthenticatedUserSchema,
|
||||
CreditSummarySchema,
|
||||
RegistrationSendRequestSchema,
|
||||
RegistrationSendResponseSchema,
|
||||
RegistrationCompleteRequestSchema,
|
||||
RegistrationCompleteHeadersSchema,
|
||||
RegistrationCompleteResponseSchema,
|
||||
UserSessionResponseSchema,
|
||||
BrowserUnsupportedReasonSchema,
|
||||
BrowserSupportRequestSchema,
|
||||
BrowserSupportSuccessSchema,
|
||||
@@ -230,6 +288,144 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/auth/register/send",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(RegistrationSendRequestSchema),
|
||||
operationId: "sendRegistrationCode",
|
||||
response: {
|
||||
200: Type.Ref(RegistrationSendResponseSchema),
|
||||
400: Type.Ref(ErrorEnvelopeSchema),
|
||||
409: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Authentication"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return registrationValidationFailure(reply, request.id);
|
||||
if (!options.registration) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
try {
|
||||
const body = request.body as RegistrationSendRequest;
|
||||
const result = await options.registration.sendRegistrationCode({ email: body.email, inviteCode: body.invite_code });
|
||||
return {
|
||||
challenge_expires_at: new Date(result.challengeExpiresAt).toISOString(),
|
||||
registration_id: result.registrationId,
|
||||
resend_available_at: new Date(result.resendAvailableAt).toISOString(),
|
||||
status: result.status,
|
||||
};
|
||||
} catch (error) {
|
||||
return registrationFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/auth/register/complete",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(RegistrationCompleteRequestSchema),
|
||||
headers: Type.Ref(RegistrationCompleteHeadersSchema),
|
||||
operationId: "completeRegistration",
|
||||
response: {
|
||||
200: Type.Ref(RegistrationCompleteResponseSchema),
|
||||
400: Type.Ref(ErrorEnvelopeSchema),
|
||||
409: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Authentication"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return registrationValidationFailure(reply, request.id);
|
||||
if (!options.registration) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
try {
|
||||
const body = request.body as RegistrationCompleteRequest;
|
||||
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
|
||||
if (!idempotencyKey) return registrationValidationFailure(reply, request.id);
|
||||
const result = options.registration.completeRegistration({
|
||||
code: body.verification_code,
|
||||
creatorName: body.creator_name,
|
||||
idempotencyKey,
|
||||
privacyConsentAccepted: body.privacy_consent_accepted,
|
||||
privacyNoticeVersion: body.privacy_notice_version,
|
||||
registrationId: body.registration_id,
|
||||
socialId: body.social_id,
|
||||
});
|
||||
reply.header(
|
||||
"Set-Cookie",
|
||||
`dada_session=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
|
||||
);
|
||||
return {
|
||||
credits: {
|
||||
available_balance: result.credits.availableBalance,
|
||||
reserved_balance: result.credits.reservedBalance,
|
||||
},
|
||||
session_expires_at: new Date(result.sessionExpiresAt).toISOString(),
|
||||
status: result.status,
|
||||
user: {
|
||||
creator_name: result.user.creatorName,
|
||||
role: result.user.role,
|
||||
social_id: result.user.socialId,
|
||||
status: result.user.status,
|
||||
user_id: result.user.userId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return registrationFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/auth/session",
|
||||
{
|
||||
schema: {
|
||||
operationId: "getUserSession",
|
||||
response: {
|
||||
200: Type.Ref(UserSessionResponseSchema),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Authentication"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!options.registration) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), "dada_session");
|
||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
||||
if (!session) {
|
||||
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
}
|
||||
return {
|
||||
audience: session.audience,
|
||||
authenticated: true as const,
|
||||
credits: {
|
||||
available_balance: session.credits.availableBalance,
|
||||
reserved_balance: session.credits.reservedBalance,
|
||||
},
|
||||
csrf_token: randomBytes(32).toString("base64url"),
|
||||
expires_at: new Date(session.expiresAt).toISOString(),
|
||||
user: {
|
||||
creator_name: session.user.creatorName,
|
||||
role: session.user.role,
|
||||
social_id: session.user.socialId,
|
||||
status: session.user.status,
|
||||
user_id: session.user.userId,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/support/check",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export type RegistrationErrorReason =
|
||||
| "invite_not_found"
|
||||
| "invite_expired"
|
||||
| "invite_disabled"
|
||||
| "invite_exhausted"
|
||||
| "stage_limit_reached"
|
||||
| "email_already_registered"
|
||||
| "challenge_invalid"
|
||||
| "challenge_expired"
|
||||
| "privacy_consent_required"
|
||||
| "privacy_notice_version_invalid"
|
||||
| "profile_invalid"
|
||||
| "idempotency_conflict";
|
||||
|
||||
export type RegistrationErrorCode =
|
||||
| "REGISTRATION_REJECTED"
|
||||
| "REGISTRATION_REQUEST_INVALID"
|
||||
| "IDEMPOTENCY_KEY_CONFLICT";
|
||||
|
||||
export class RegistrationError extends Error {
|
||||
readonly code: RegistrationErrorCode;
|
||||
readonly httpStatus: 400 | 409;
|
||||
readonly reason: RegistrationErrorReason;
|
||||
|
||||
constructor(code: RegistrationErrorCode, reason: RegistrationErrorReason) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
this.httpStatus = code === "REGISTRATION_REQUEST_INVALID" ? 400 : 409;
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
export function registrationFieldError(reason: RegistrationErrorReason) {
|
||||
const entries: Record<RegistrationErrorReason, { field: string; message_key: string }> = {
|
||||
challenge_expired: { field: "verification_code", message_key: "auth.challenge.expired" },
|
||||
challenge_invalid: { field: "verification_code", message_key: "auth.challenge.invalid" },
|
||||
email_already_registered: { field: "email", message_key: "auth.email.already_registered" },
|
||||
idempotency_conflict: { field: "idempotency_key", message_key: "request.idempotency_conflict" },
|
||||
invite_disabled: { field: "invite_code", message_key: "auth.invite.disabled" },
|
||||
invite_exhausted: { field: "invite_code", message_key: "auth.invite.exhausted" },
|
||||
invite_expired: { field: "invite_code", message_key: "auth.invite.expired" },
|
||||
invite_not_found: { field: "invite_code", message_key: "auth.invite.not_found" },
|
||||
privacy_consent_required: { field: "privacy_consent_accepted", message_key: "auth.privacy.consent_required" },
|
||||
privacy_notice_version_invalid: { field: "privacy_notice_version", message_key: "auth.privacy.notice_version_invalid" },
|
||||
profile_invalid: { field: "profile", message_key: "auth.profile.invalid" },
|
||||
stage_limit_reached: { field: "invite_code", message_key: "auth.registration.stage_limit_reached" },
|
||||
};
|
||||
return entries[reason];
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import type BetterSqlite3 from "better-sqlite3";
|
||||
|
||||
import type { ResendAdapter } from "./resend-adapter.js";
|
||||
import {
|
||||
RegistrationError,
|
||||
type RegistrationErrorReason,
|
||||
} from "./registration-errors.js";
|
||||
|
||||
export { RegistrationError } from "./registration-errors.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3") as typeof BetterSqlite3;
|
||||
const stageLimit = 10;
|
||||
const challengeLifetimeMilliseconds = 10 * 60 * 1_000;
|
||||
const resendDelayMilliseconds = 60 * 1_000;
|
||||
const sessionLifetimeMilliseconds = 30 * 24 * 60 * 60 * 1_000;
|
||||
|
||||
export interface RegistrationTransactionEvent {
|
||||
mode: "BEGIN IMMEDIATE";
|
||||
operation: "invite_create" | "registration_send" | "registration_complete" | "registration_send_compensation";
|
||||
outcome: "committed" | "rejected" | "idempotent_replay";
|
||||
}
|
||||
|
||||
interface RegistrationServiceOptions {
|
||||
challengePepper: Buffer;
|
||||
clock?: () => number;
|
||||
codeGenerator?: () => string;
|
||||
currentPrivacyNoticeVersion: string;
|
||||
databasePath: string;
|
||||
inviteCodeGenerator?: () => string;
|
||||
invitePepper: Buffer;
|
||||
onTransaction?: (event: RegistrationTransactionEvent) => void;
|
||||
resend: ResendAdapter;
|
||||
sessionPepper: Buffer;
|
||||
}
|
||||
|
||||
interface InviteRow {
|
||||
expires_at: number;
|
||||
invite_id: string;
|
||||
max_uses: number;
|
||||
status: "enabled" | "disabled";
|
||||
used_count: number;
|
||||
}
|
||||
|
||||
interface ChallengeRow {
|
||||
challenge_id: string;
|
||||
code_hmac: string;
|
||||
consumed_at: number | null;
|
||||
email: string;
|
||||
expires_at: number;
|
||||
invite_id: string;
|
||||
}
|
||||
|
||||
interface AttemptRow {
|
||||
failure_reason: RegistrationErrorReason | null;
|
||||
outcome_code: "success" | "failure";
|
||||
request_hash: string;
|
||||
session_id: string | null;
|
||||
user_id: string | null;
|
||||
}
|
||||
|
||||
interface UserResultRow {
|
||||
available_balance: number;
|
||||
creator_name: string;
|
||||
expires_at: number;
|
||||
reserved_balance: number;
|
||||
session_id: string;
|
||||
social_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface RegistrationSendResult {
|
||||
challengeExpiresAt: number;
|
||||
registrationId: string;
|
||||
resendAvailableAt: number;
|
||||
status: "verification_sent";
|
||||
}
|
||||
|
||||
export interface RegistrationCompleteInput {
|
||||
code: string;
|
||||
creatorName: string;
|
||||
idempotencyKey: string;
|
||||
privacyConsentAccepted: boolean;
|
||||
privacyNoticeVersion: string;
|
||||
registrationId: string;
|
||||
socialId: string;
|
||||
}
|
||||
|
||||
export interface RegistrationCompleteResult {
|
||||
credits: { availableBalance: number; reservedBalance: number };
|
||||
sessionExpiresAt: number;
|
||||
sessionToken: string;
|
||||
status: "registered";
|
||||
user: {
|
||||
creatorName: string;
|
||||
role: "user";
|
||||
socialId: string;
|
||||
status: "active";
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserSessionResult {
|
||||
audience: "user";
|
||||
credits: { availableBalance: number; reservedBalance: number };
|
||||
expiresAt: number;
|
||||
user: RegistrationCompleteResult["user"];
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface ImmediateResult<T> {
|
||||
outcome: RegistrationTransactionEvent["outcome"];
|
||||
value: T;
|
||||
}
|
||||
|
||||
function assertSecret(name: string, value: Buffer) {
|
||||
if (value.byteLength < 32) throw new Error(`${name} must contain at least 32 bytes.`);
|
||||
}
|
||||
|
||||
function normalizeEmail(email: string) {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
if (normalized.length > 320 || !/^[^@\s]{1,128}@[^@\s]{1,190}$/.test(normalized)) {
|
||||
throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeProfileValue(value: string, maximumLength: number) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized.length > maximumLength) {
|
||||
throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSocialId(value: string) {
|
||||
const body = normalizeProfileValue(value, 80).replace(/^@+/, "");
|
||||
if (!body) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid");
|
||||
return `@${body}`;
|
||||
}
|
||||
|
||||
function digest(value: string) {
|
||||
return createHash("sha256").update(value, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function constantTimeTextEqual(left: string, right: string) {
|
||||
const leftBuffer = Buffer.from(left, "hex");
|
||||
const rightBuffer = Buffer.from(right, "hex");
|
||||
return leftBuffer.byteLength === rightBuffer.byteLength && timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
export class RegistrationService {
|
||||
readonly database: BetterSqlite3.Database;
|
||||
readonly options: Required<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
|
||||
|
||||
constructor(options: RegistrationServiceOptions) {
|
||||
assertSecret("invitePepper", options.invitePepper);
|
||||
assertSecret("challengePepper", options.challengePepper);
|
||||
assertSecret("sessionPepper", options.sessionPepper);
|
||||
this.options = {
|
||||
...options,
|
||||
clock: options.clock ?? Date.now,
|
||||
codeGenerator: options.codeGenerator ?? (() => String(randomBytes(4).readUInt32BE(0) % 1_000_000).padStart(6, "0")),
|
||||
inviteCodeGenerator: options.inviteCodeGenerator ?? (() => randomBytes(24).toString("base64url")),
|
||||
};
|
||||
this.database = new Database(options.databasePath);
|
||||
this.database.pragma("journal_mode = WAL");
|
||||
this.database.pragma("foreign_keys = ON");
|
||||
this.database.pragma("synchronous = FULL");
|
||||
this.database.pragma("busy_timeout = 5000");
|
||||
this.migrate();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.database.close();
|
||||
}
|
||||
|
||||
createInvite(input: { expiresAt: number; maxUses: number }) {
|
||||
if (!Number.isSafeInteger(input.expiresAt) || !Number.isSafeInteger(input.maxUses) || input.maxUses < 1) {
|
||||
throw new Error("Invite fixture is invalid.");
|
||||
}
|
||||
const code = this.options.inviteCodeGenerator();
|
||||
const inviteId = randomUUID();
|
||||
const now = this.options.clock();
|
||||
this.runImmediate("invite_create", () => {
|
||||
this.database.prepare(`
|
||||
INSERT INTO invite_codes (
|
||||
invite_id, code_hmac, max_uses, used_count, expires_at, status, created_at
|
||||
) VALUES (?, ?, ?, 0, ?, 'enabled', ?)
|
||||
`).run(inviteId, this.inviteHmac(code), input.maxUses, input.expiresAt, now);
|
||||
return { outcome: "committed", value: undefined };
|
||||
});
|
||||
return { code, inviteId };
|
||||
}
|
||||
|
||||
async sendRegistrationCode(input: { email: string; inviteCode: string }): Promise<RegistrationSendResult> {
|
||||
const email = normalizeEmail(input.email);
|
||||
const inviteCode = normalizeProfileValue(input.inviteCode, 160);
|
||||
const now = this.options.clock();
|
||||
const challengeId = randomUUID();
|
||||
const code = this.options.codeGenerator();
|
||||
if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits.");
|
||||
|
||||
const result = this.runImmediate("registration_send", () => {
|
||||
const invite = this.database.prepare("SELECT * FROM invite_codes WHERE code_hmac = ?")
|
||||
.get(this.inviteHmac(inviteCode)) as InviteRow | undefined;
|
||||
this.assertInviteAvailable(invite, now);
|
||||
this.assertStageCapacity();
|
||||
const existing = this.database.prepare(`
|
||||
SELECT user_id FROM users
|
||||
WHERE normalized_email = ? AND status <> 'deleted'
|
||||
`).get(email);
|
||||
if (existing) throw new RegistrationError("REGISTRATION_REJECTED", "email_already_registered");
|
||||
|
||||
this.database.prepare(`
|
||||
INSERT INTO email_challenges (
|
||||
challenge_id, email, invite_id, code_hmac, purpose, expires_at,
|
||||
resend_available_at, failure_count, consumed_at, created_at
|
||||
) VALUES (?, ?, ?, ?, 'register', ?, ?, 0, NULL, ?)
|
||||
`).run(
|
||||
challengeId,
|
||||
email,
|
||||
invite!.invite_id,
|
||||
this.challengeHmac(challengeId, code),
|
||||
now + challengeLifetimeMilliseconds,
|
||||
now + resendDelayMilliseconds,
|
||||
now,
|
||||
);
|
||||
return {
|
||||
outcome: "committed",
|
||||
value: {
|
||||
challengeExpiresAt: now + challengeLifetimeMilliseconds,
|
||||
registrationId: challengeId,
|
||||
resendAvailableAt: now + resendDelayMilliseconds,
|
||||
status: "verification_sent" as const,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await this.options.resend.sendRegistrationCode({ challengeId, code, email, purpose: "register" });
|
||||
} catch {
|
||||
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 };
|
||||
});
|
||||
throw new Error("AUTH_SERVICE_UNAVAILABLE");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
completeRegistration(input: RegistrationCompleteInput): RegistrationCompleteResult {
|
||||
const creatorName = normalizeProfileValue(input.creatorName, 80);
|
||||
const socialId = normalizeSocialId(input.socialId);
|
||||
if (!/^[0-9]{6}$/.test(input.code)) {
|
||||
throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid");
|
||||
}
|
||||
if (!input.privacyConsentAccepted) {
|
||||
throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "privacy_consent_required");
|
||||
}
|
||||
if (input.privacyNoticeVersion !== this.options.currentPrivacyNoticeVersion) {
|
||||
throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "privacy_notice_version_invalid");
|
||||
}
|
||||
if (input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) {
|
||||
throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "idempotency_conflict");
|
||||
}
|
||||
const now = this.options.clock();
|
||||
const idempotencyDigest = this.keyedHmac(this.options.sessionPepper, `idempotency:${input.idempotencyKey}`);
|
||||
const requestHash = this.keyedHmac(this.options.challengePepper, JSON.stringify({
|
||||
code: input.code,
|
||||
creatorName,
|
||||
privacyConsentAccepted: input.privacyConsentAccepted,
|
||||
privacyNoticeVersion: input.privacyNoticeVersion,
|
||||
registrationId: input.registrationId,
|
||||
socialId,
|
||||
}));
|
||||
|
||||
const outcome = this.runImmediate<RegistrationCompleteResult | RegistrationError>("registration_complete", () => {
|
||||
const previous = this.database.prepare(`
|
||||
SELECT request_hash, outcome_code, failure_reason, user_id, session_id
|
||||
FROM registration_attempts WHERE idempotency_key_digest = ?
|
||||
`).get(idempotencyDigest) as AttemptRow | undefined;
|
||||
if (previous) {
|
||||
if (!constantTimeTextEqual(previous.request_hash, requestHash)) {
|
||||
throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict");
|
||||
}
|
||||
if (previous.outcome_code === "failure") {
|
||||
return {
|
||||
outcome: "idempotent_replay",
|
||||
value: new RegistrationError("REGISTRATION_REJECTED", previous.failure_reason ?? "challenge_invalid"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
outcome: "idempotent_replay",
|
||||
value: this.readCompletedRegistration(previous.user_id!, previous.session_id!),
|
||||
};
|
||||
}
|
||||
|
||||
const challenge = this.database.prepare(`
|
||||
SELECT challenge_id, email, invite_id, code_hmac, expires_at, consumed_at
|
||||
FROM email_challenges WHERE challenge_id = ? AND purpose = 'register'
|
||||
`).get(input.registrationId) as ChallengeRow | undefined;
|
||||
if (!challenge || challenge.consumed_at !== null) {
|
||||
return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now);
|
||||
}
|
||||
const invite = this.database.prepare("SELECT * FROM invite_codes WHERE invite_id = ?")
|
||||
.get(challenge.invite_id) as InviteRow | undefined;
|
||||
const inviteReason = this.inviteUnavailableReason(invite, now);
|
||||
if (inviteReason) return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, inviteReason, now);
|
||||
if (this.stageIsFull()) {
|
||||
return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "stage_limit_reached", now);
|
||||
}
|
||||
if (challenge.expires_at <= now) {
|
||||
return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "challenge_expired", now);
|
||||
}
|
||||
if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(challenge.challenge_id, input.code))) {
|
||||
return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now);
|
||||
}
|
||||
const existing = this.database.prepare(`
|
||||
SELECT user_id FROM users WHERE normalized_email = ? AND status <> 'deleted'
|
||||
`).get(challenge.email);
|
||||
if (existing) {
|
||||
return this.recordRejectedAttempt(idempotencyDigest, requestHash, input.registrationId, "email_already_registered", now);
|
||||
}
|
||||
|
||||
const userId = randomUUID();
|
||||
const sessionId = randomUUID();
|
||||
const sessionToken = this.sessionToken(sessionId);
|
||||
const sessionExpiresAt = now + sessionLifetimeMilliseconds;
|
||||
this.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, challenge.email, input.registrationId, now);
|
||||
this.database.prepare(`
|
||||
INSERT INTO user_profiles (
|
||||
user_id, creator_name, social_id, private_content_notice_version,
|
||||
private_content_notice_acknowledged_at
|
||||
) VALUES (?, ?, ?, NULL, NULL)
|
||||
`).run(userId, creatorName, socialId);
|
||||
this.database.prepare(`
|
||||
INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at)
|
||||
VALUES (?, 10, 0, ?)
|
||||
`).run(userId, now);
|
||||
this.database.prepare(`
|
||||
INSERT INTO credit_ledger (
|
||||
ledger_id, user_id, operation_key, entry_type, amount,
|
||||
available_before, available_after, reserved_before, reserved_after, created_at
|
||||
) VALUES (?, ?, ?, 'registration_grant', 10, 0, 10, 0, 0, ?)
|
||||
`).run(randomUUID(), userId, `registration:${input.registrationId}`, now);
|
||||
this.database.prepare(`
|
||||
INSERT INTO privacy_consents (consent_id, user_id, notice_version, consented_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(randomUUID(), userId, input.privacyNoticeVersion, now);
|
||||
this.database.prepare(`
|
||||
UPDATE invite_codes SET used_count = used_count + 1
|
||||
WHERE invite_id = ? AND status = 'enabled' AND used_count < max_uses AND expires_at > ?
|
||||
`).run(challenge.invite_id, now);
|
||||
this.database.prepare("UPDATE email_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL")
|
||||
.run(now, challenge.challenge_id);
|
||||
this.database.prepare(`
|
||||
INSERT INTO sessions (
|
||||
session_id, user_id, audience, token_digest, created_at, expires_at, revoked_at
|
||||
) VALUES (?, ?, 'user', ?, ?, ?, NULL)
|
||||
`).run(sessionId, userId, digest(sessionToken), now, sessionExpiresAt);
|
||||
this.database.prepare(`
|
||||
INSERT INTO registration_attempts (
|
||||
idempotency_key_digest, request_hash, registration_id, outcome_code,
|
||||
failure_reason, user_id, session_id, created_at
|
||||
) VALUES (?, ?, ?, 'success', NULL, ?, ?, ?)
|
||||
`).run(idempotencyDigest, requestHash, input.registrationId, userId, sessionId, now);
|
||||
|
||||
return {
|
||||
outcome: "committed",
|
||||
value: this.result({
|
||||
available_balance: 10,
|
||||
creator_name: creatorName,
|
||||
expires_at: sessionExpiresAt,
|
||||
reserved_balance: 0,
|
||||
session_id: sessionId,
|
||||
social_id: socialId,
|
||||
user_id: userId,
|
||||
}),
|
||||
};
|
||||
});
|
||||
if (outcome instanceof RegistrationError) throw outcome;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
readUserSession(token: string): UserSessionResult | undefined {
|
||||
const now = this.options.clock();
|
||||
const row = this.database.prepare(`
|
||||
SELECT
|
||||
s.expires_at, u.user_id, p.creator_name, p.social_id,
|
||||
c.available_balance, c.reserved_balance
|
||||
FROM sessions s
|
||||
JOIN users u ON u.user_id = s.user_id
|
||||
JOIN user_profiles p ON p.user_id = u.user_id
|
||||
JOIN credit_accounts c ON c.user_id = u.user_id
|
||||
WHERE s.token_digest = ? AND s.audience = 'user' AND s.revoked_at IS NULL
|
||||
AND s.expires_at > ? AND u.role = 'user' AND u.status = 'active'
|
||||
`).get(digest(token), now) as Omit<UserResultRow, "session_id"> | undefined;
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
audience: "user",
|
||||
credits: { availableBalance: row.available_balance, reservedBalance: row.reserved_balance },
|
||||
expiresAt: row.expires_at,
|
||||
user: {
|
||||
creatorName: row.creator_name,
|
||||
role: "user",
|
||||
socialId: row.social_id,
|
||||
status: "active",
|
||||
userId: row.user_id,
|
||||
},
|
||||
userId: row.user_id,
|
||||
};
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
normalized_email TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'super_admin')),
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'suspended', 'deleted')),
|
||||
counts_toward_stage_limit INTEGER NOT NULL CHECK (counts_toward_stage_limit IN (0, 1)),
|
||||
registration_id TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_current_email_unique
|
||||
ON users(normalized_email) WHERE status <> 'deleted';
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(user_id),
|
||||
creator_name TEXT NOT NULL,
|
||||
social_id TEXT NOT NULL,
|
||||
private_content_notice_version TEXT,
|
||||
private_content_notice_acknowledged_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS credit_accounts (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(user_id),
|
||||
available_balance INTEGER NOT NULL,
|
||||
reserved_balance INTEGER NOT NULL CHECK (reserved_balance >= 0),
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS credit_ledger (
|
||||
ledger_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id),
|
||||
operation_key TEXT NOT NULL UNIQUE,
|
||||
entry_type TEXT NOT NULL CHECK (entry_type IN (
|
||||
'registration_grant', 'generation_reserve', 'generation_commit',
|
||||
'generation_release', 'admin_adjustment'
|
||||
)),
|
||||
amount INTEGER NOT NULL,
|
||||
available_before INTEGER NOT NULL,
|
||||
available_after INTEGER NOT NULL,
|
||||
reserved_before INTEGER NOT NULL CHECK (reserved_before >= 0),
|
||||
reserved_after INTEGER NOT NULL CHECK (reserved_after >= 0),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS credit_ledger_no_update
|
||||
BEFORE UPDATE ON credit_ledger BEGIN SELECT RAISE(ABORT, 'credit_ledger_immutable'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS credit_ledger_no_delete
|
||||
BEFORE DELETE ON credit_ledger BEGIN SELECT RAISE(ABORT, 'credit_ledger_immutable'); END;
|
||||
CREATE TABLE IF NOT EXISTS privacy_consents (
|
||||
consent_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id),
|
||||
notice_version TEXT NOT NULL,
|
||||
consented_at INTEGER NOT NULL,
|
||||
UNIQUE(user_id, notice_version)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
invite_id TEXT PRIMARY KEY,
|
||||
code_hmac TEXT NOT NULL UNIQUE,
|
||||
max_uses INTEGER NOT NULL CHECK (max_uses > 0),
|
||||
used_count INTEGER NOT NULL CHECK (used_count >= 0 AND used_count <= max_uses),
|
||||
expires_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('enabled', 'disabled')),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS email_challenges (
|
||||
challenge_id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
invite_id TEXT NOT NULL REFERENCES invite_codes(invite_id),
|
||||
code_hmac TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login', 'admin_login')),
|
||||
expires_at INTEGER NOT NULL,
|
||||
resend_available_at INTEGER NOT NULL,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0 CHECK (failure_count >= 0),
|
||||
consumed_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id),
|
||||
audience TEXT NOT NULL CHECK (audience IN ('user', 'admin')),
|
||||
token_digest TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
revoked_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS registration_attempts (
|
||||
idempotency_key_digest TEXT PRIMARY KEY,
|
||||
request_hash TEXT NOT NULL,
|
||||
registration_id TEXT NOT NULL,
|
||||
outcome_code TEXT NOT NULL CHECK (outcome_code IN ('success', 'failure')),
|
||||
failure_reason TEXT,
|
||||
user_id TEXT REFERENCES users(user_id),
|
||||
session_id TEXT REFERENCES sessions(session_id),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
private runImmediate<T>(
|
||||
operation: RegistrationTransactionEvent["operation"],
|
||||
action: () => ImmediateResult<T>,
|
||||
): T {
|
||||
this.database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = action();
|
||||
this.database.exec("COMMIT");
|
||||
this.options.onTransaction?.({ mode: "BEGIN IMMEDIATE", operation, outcome: result.outcome });
|
||||
return result.value;
|
||||
} catch (error) {
|
||||
if (this.database.inTransaction) this.database.exec("ROLLBACK");
|
||||
this.options.onTransaction?.({ mode: "BEGIN IMMEDIATE", operation, outcome: "rejected" });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private keyedHmac(key: Buffer, value: string) {
|
||||
return createHmac("sha256", key).update(value, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
private inviteHmac(code: string) {
|
||||
return this.keyedHmac(this.options.invitePepper, code.trim());
|
||||
}
|
||||
|
||||
private challengeHmac(challengeId: string, code: string) {
|
||||
return this.keyedHmac(this.options.challengePepper, `${challengeId}:${code}`);
|
||||
}
|
||||
|
||||
private sessionToken(sessionId: string) {
|
||||
return createHmac("sha256", this.options.sessionPepper).update(`session:${sessionId}`, "utf8").digest("base64url");
|
||||
}
|
||||
|
||||
private stageIsFull() {
|
||||
const row = this.database.prepare(`
|
||||
SELECT COUNT(*) AS count FROM users
|
||||
WHERE role = 'user' AND counts_toward_stage_limit = 1
|
||||
AND status IN ('active', 'suspended')
|
||||
`).get() as { count: number };
|
||||
return row.count >= stageLimit;
|
||||
}
|
||||
|
||||
private assertStageCapacity() {
|
||||
if (this.stageIsFull()) throw new RegistrationError("REGISTRATION_REJECTED", "stage_limit_reached");
|
||||
}
|
||||
|
||||
private inviteUnavailableReason(invite: InviteRow | undefined, now: number): RegistrationErrorReason | undefined {
|
||||
if (!invite) return "invite_not_found";
|
||||
if (invite.status !== "enabled") return "invite_disabled";
|
||||
if (invite.expires_at <= now) return "invite_expired";
|
||||
if (invite.used_count >= invite.max_uses) return "invite_exhausted";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private assertInviteAvailable(invite: InviteRow | undefined, now: number): asserts invite is InviteRow {
|
||||
const reason = this.inviteUnavailableReason(invite, now);
|
||||
if (reason) throw new RegistrationError("REGISTRATION_REJECTED", reason);
|
||||
}
|
||||
|
||||
private recordRejectedAttempt(
|
||||
idempotencyKeyDigest: string,
|
||||
requestHash: string,
|
||||
registrationId: string,
|
||||
reason: RegistrationErrorReason,
|
||||
now: number,
|
||||
): ImmediateResult<RegistrationError> {
|
||||
this.database.prepare(`
|
||||
INSERT INTO registration_attempts (
|
||||
idempotency_key_digest, request_hash, registration_id, outcome_code,
|
||||
failure_reason, user_id, session_id, created_at
|
||||
) VALUES (?, ?, ?, 'failure', ?, NULL, NULL, ?)
|
||||
`).run(idempotencyKeyDigest, requestHash, registrationId, reason, now);
|
||||
return { outcome: "rejected", value: new RegistrationError("REGISTRATION_REJECTED", reason) };
|
||||
}
|
||||
|
||||
private readCompletedRegistration(userId: string, sessionId: string) {
|
||||
const row = this.database.prepare(`
|
||||
SELECT
|
||||
u.user_id, p.creator_name, p.social_id, c.available_balance,
|
||||
c.reserved_balance, s.session_id, s.expires_at
|
||||
FROM users u
|
||||
JOIN user_profiles p ON p.user_id = u.user_id
|
||||
JOIN credit_accounts c ON c.user_id = u.user_id
|
||||
JOIN sessions s ON s.user_id = u.user_id
|
||||
WHERE u.user_id = ? AND s.session_id = ?
|
||||
`).get(userId, sessionId) as UserResultRow | undefined;
|
||||
if (!row) throw new RegistrationError("REGISTRATION_REJECTED", "challenge_invalid");
|
||||
return this.result(row);
|
||||
}
|
||||
|
||||
private result(row: UserResultRow): RegistrationCompleteResult {
|
||||
return {
|
||||
credits: { availableBalance: row.available_balance, reservedBalance: row.reserved_balance },
|
||||
sessionExpiresAt: row.expires_at,
|
||||
sessionToken: this.sessionToken(row.session_id),
|
||||
status: "registered",
|
||||
user: {
|
||||
creatorName: row.creator_name,
|
||||
role: "user",
|
||||
socialId: row.social_id,
|
||||
status: "active",
|
||||
userId: row.user_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface RegistrationCodeMessage {
|
||||
challengeId: string;
|
||||
code: string;
|
||||
email: string;
|
||||
purpose: "register";
|
||||
}
|
||||
|
||||
export interface ResendAdapter {
|
||||
sendRegistrationCode(message: RegistrationCodeMessage): Promise<void>;
|
||||
}
|
||||
|
||||
export class MockResendAdapter implements ResendAdapter {
|
||||
readonly calls: RegistrationCodeMessage[] = [];
|
||||
|
||||
async sendRegistrationCode(message: RegistrationCodeMessage) {
|
||||
this.calls.push({ ...message });
|
||||
}
|
||||
|
||||
readLatestCode(email: string) {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const call = this.calls.findLast((candidate) => candidate.email === normalizedEmail);
|
||||
if (!call) throw new Error("No mock registration message exists for that email.");
|
||||
return call.code;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
import type { RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, RegistrationSendResponse, RegistrationSendRequest } from "./types.gen.js";
|
||||
|
||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||
|
||||
export async function checkBrowserSupport(body: {
|
||||
@@ -43,6 +45,15 @@ export async function checkBrowserSupport(body: {
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function completeRegistration(body: RegistrationCompleteRequest, options: ClientOptions = {}): Promise<RegistrationCompleteResponse> {
|
||||
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/auth/register/complete`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<RegistrationCompleteResponse>;
|
||||
}
|
||||
|
||||
export async function getBootstrap(options: ClientOptions = {}): Promise<{
|
||||
"app_version": string;
|
||||
"dependencies": Array<{
|
||||
@@ -85,3 +96,19 @@ export async function getBootstrap(options: ClientOptions = {}): Promise<{
|
||||
export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string {
|
||||
return `${options.baseUrl ?? ""}/api/v1/events`;
|
||||
}
|
||||
|
||||
export async function getUserSession(options: ClientOptions = {}): Promise<UserSessionResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/session`, { method: "GET", headers: options.headers ?? {} });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<UserSessionResponse>;
|
||||
}
|
||||
|
||||
export async function sendRegistrationCode(body: RegistrationSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||
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/auth/register/send`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<RegistrationSendResponse>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
export type AuthenticatedUser = {
|
||||
"creator_name": string;
|
||||
"role": "user";
|
||||
"social_id": string;
|
||||
"status": "active";
|
||||
"user_id": string;
|
||||
};
|
||||
|
||||
export type BootstrapResponse = {
|
||||
"app_version": string;
|
||||
"dependencies": Array<{
|
||||
@@ -47,6 +55,11 @@ export type BrowserUnsupportedReason = "platform_unsupported" | "brand_unsupport
|
||||
|
||||
export type CorrelationId = string;
|
||||
|
||||
export type CreditSummary = {
|
||||
"available_balance": number;
|
||||
"reserved_balance": number;
|
||||
};
|
||||
|
||||
export type ErrorDetails = {
|
||||
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||
"current_task_ref"?: string;
|
||||
@@ -65,7 +78,7 @@ export type ErrorDetails = {
|
||||
|
||||
export type ErrorEnvelope = {
|
||||
"error": {
|
||||
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED";
|
||||
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE";
|
||||
"correlation_id": string;
|
||||
"details": {
|
||||
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
||||
@@ -105,6 +118,38 @@ export type ModelRuntimeSseEvent = {
|
||||
"runtime_availability_version": number;
|
||||
};
|
||||
|
||||
export type RegistrationCompleteHeaders = {
|
||||
"idempotency-key": string;
|
||||
};
|
||||
|
||||
export type RegistrationCompleteRequest = {
|
||||
"creator_name": string;
|
||||
"privacy_consent_accepted": boolean;
|
||||
"privacy_notice_version": string;
|
||||
"registration_id": string;
|
||||
"social_id": string;
|
||||
"verification_code": string;
|
||||
};
|
||||
|
||||
export type RegistrationCompleteResponse = {
|
||||
"credits": CreditSummary;
|
||||
"session_expires_at": string;
|
||||
"status": "registered";
|
||||
"user": AuthenticatedUser;
|
||||
};
|
||||
|
||||
export type RegistrationSendRequest = {
|
||||
"email": string;
|
||||
"invite_code": string;
|
||||
};
|
||||
|
||||
export type RegistrationSendResponse = {
|
||||
"challenge_expires_at": string;
|
||||
"registration_id": string;
|
||||
"resend_available_at": string;
|
||||
"status": "verification_sent";
|
||||
};
|
||||
|
||||
export type SseEvent = {
|
||||
"entity_ref": string;
|
||||
"event_id": number;
|
||||
@@ -125,7 +170,7 @@ export type SseEvent = {
|
||||
"runtime_availability_version": number;
|
||||
};
|
||||
|
||||
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED";
|
||||
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE";
|
||||
|
||||
export type StateSseEvent = {
|
||||
"entity_ref": string;
|
||||
@@ -134,3 +179,12 @@ export type StateSseEvent = {
|
||||
"occurred_at": string;
|
||||
"state_version": number;
|
||||
};
|
||||
|
||||
export type UserSessionResponse = {
|
||||
"audience": "user";
|
||||
"authenticated": true;
|
||||
"credits": CreditSummary;
|
||||
"csrf_token": string;
|
||||
"expires_at": string;
|
||||
"user": AuthenticatedUser;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AuthenticatedUser": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"creator_name": {
|
||||
"maxLength": 80,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"enum": [
|
||||
"user"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"social_id": {
|
||||
"maxLength": 80,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"active"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"pattern": "^[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}$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"creator_name",
|
||||
"role",
|
||||
"social_id",
|
||||
"status",
|
||||
"user_id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"BootstrapResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -339,6 +378,23 @@
|
||||
"pattern": "^[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}$",
|
||||
"type": "string"
|
||||
},
|
||||
"CreditSummary": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"available_balance": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reserved_balance": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"available_balance",
|
||||
"reserved_balance"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ErrorDetails": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -553,6 +609,36 @@
|
||||
"STORAGE_CAPACITY_EXCEEDED"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"REGISTRATION_REJECTED"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"REGISTRATION_REQUEST_INVALID"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"IDEMPOTENCY_KEY_CONFLICT"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"AUTH_SESSION_INVALID"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"AUTH_SERVICE_UNAVAILABLE"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -915,6 +1001,139 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RegistrationCompleteHeaders": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"idempotency-key": {
|
||||
"maxLength": 200,
|
||||
"minLength": 32,
|
||||
"pattern": "^[A-Za-z0-9_-]+$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"idempotency-key"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RegistrationCompleteRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"creator_name": {
|
||||
"maxLength": 80,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"privacy_consent_accepted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"privacy_notice_version": {
|
||||
"maxLength": 80,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"registration_id": {
|
||||
"pattern": "^[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}$",
|
||||
"type": "string"
|
||||
},
|
||||
"social_id": {
|
||||
"maxLength": 80,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"verification_code": {
|
||||
"pattern": "^[0-9]{6}$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"creator_name",
|
||||
"privacy_consent_accepted",
|
||||
"privacy_notice_version",
|
||||
"registration_id",
|
||||
"social_id",
|
||||
"verification_code"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RegistrationCompleteResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"credits": {
|
||||
"$ref": "#/components/schemas/CreditSummary"
|
||||
},
|
||||
"session_expires_at": {
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"registered"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/AuthenticatedUser"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"credits",
|
||||
"session_expires_at",
|
||||
"status",
|
||||
"user"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RegistrationSendRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"email": {
|
||||
"maxLength": 320,
|
||||
"pattern": "^[^@\\s]{1,128}@[^@\\s]{1,190}$",
|
||||
"type": "string"
|
||||
},
|
||||
"invite_code": {
|
||||
"maxLength": 160,
|
||||
"minLength": 8,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"invite_code"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RegistrationSendResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"challenge_expires_at": {
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
|
||||
"type": "string"
|
||||
},
|
||||
"registration_id": {
|
||||
"pattern": "^[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}$",
|
||||
"type": "string"
|
||||
},
|
||||
"resend_available_at": {
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"verification_sent"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"challenge_expires_at",
|
||||
"registration_id",
|
||||
"resend_available_at",
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SseEvent": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1087,6 +1306,36 @@
|
||||
"STORAGE_CAPACITY_EXCEEDED"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"REGISTRATION_REJECTED"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"REGISTRATION_REQUEST_INVALID"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"IDEMPOTENCY_KEY_CONFLICT"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"AUTH_SESSION_INVALID"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"AUTH_SERVICE_UNAVAILABLE"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1124,6 +1373,48 @@
|
||||
"state_version"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"UserSessionResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"audience": {
|
||||
"enum": [
|
||||
"user"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"authenticated": {
|
||||
"enum": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"credits": {
|
||||
"$ref": "#/components/schemas/CreditSummary"
|
||||
},
|
||||
"csrf_token": {
|
||||
"maxLength": 64,
|
||||
"minLength": 43,
|
||||
"pattern": "^[A-Za-z0-9_-]+$",
|
||||
"type": "string"
|
||||
},
|
||||
"expires_at": {
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$",
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/AuthenticatedUser"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"audience",
|
||||
"authenticated",
|
||||
"credits",
|
||||
"csrf_token",
|
||||
"expires_at",
|
||||
"user"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1133,6 +1424,179 @@
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
"/api/v1/auth/register/complete": {
|
||||
"post": {
|
||||
"operationId": "completeRegistration",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "idempotency-key",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"maxLength": 200,
|
||||
"minLength": 32,
|
||||
"pattern": "^[A-Za-z0-9_-]+$",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegistrationCompleteRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegistrationCompleteResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Authentication"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/register/send": {
|
||||
"post": {
|
||||
"operationId": "sendRegistrationCode",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegistrationSendRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegistrationSendResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Authentication"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/session": {
|
||||
"get": {
|
||||
"operationId": "getUserSession",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserSessionResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Authentication"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/bootstrap": {
|
||||
"get": {
|
||||
"operationId": "getBootstrap",
|
||||
@@ -1375,6 +1839,36 @@
|
||||
"STORAGE_CAPACITY_EXCEEDED"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"REGISTRATION_REJECTED"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"REGISTRATION_REQUEST_INVALID"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"IDEMPOTENCY_KEY_CONFLICT"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"AUTH_SESSION_INVALID"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"AUTH_SERVICE_UNAVAILABLE"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1809,6 +2303,36 @@
|
||||
"STORAGE_CAPACITY_EXCEEDED"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"REGISTRATION_REJECTED"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"REGISTRATION_REQUEST_INVALID"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"IDEMPOTENCY_KEY_CONFLICT"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"AUTH_SESSION_INVALID"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"AUTH_SERVICE_UNAVAILABLE"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2284,6 +2808,36 @@
|
||||
"STORAGE_CAPACITY_EXCEEDED"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"REGISTRATION_REJECTED"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"REGISTRATION_REQUEST_INVALID"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"IDEMPOTENCY_KEY_CONFLICT"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"AUTH_SESSION_INVALID"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"AUTH_SERVICE_UNAVAILABLE"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+3
-1
@@ -39,7 +39,9 @@
|
||||
"test:wp0-08": "node scripts/run-wp0-08-validation.mjs",
|
||||
"test:wp0-08:red": "node scripts/run-wp0-08-validation.mjs --phase red",
|
||||
"test:wp0-09": "node scripts/run-wp0-09-validation.mjs",
|
||||
"test:wp0-09:red": "node scripts/run-wp0-09-validation.mjs --phase red"
|
||||
"test:wp0-09:red": "node scripts/run-wp0-09-validation.mjs --phase red",
|
||||
"test:wp1-01": "node scripts/run-wp1-01-validation.mjs",
|
||||
"test:wp1-01:red": "node scripts/run-wp1-01-validation.mjs --phase red"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.0",
|
||||
|
||||
@@ -14,6 +14,11 @@ export const stableEngineeringErrors = {
|
||||
ASSET_HISTORY_REFERENCE_CONFLICT: { httpStatus: 409, messageKey: "ASSET_HISTORY_REFERENCE_CONFLICT" },
|
||||
ASSET_CLEANUP_CANDIDATE_STALE: { httpStatus: 409, messageKey: "ASSET_CLEANUP_CANDIDATE_STALE" },
|
||||
STORAGE_CAPACITY_EXCEEDED: { httpStatus: 507, messageKey: "STORAGE_CAPACITY_EXCEEDED" },
|
||||
REGISTRATION_REJECTED: { httpStatus: 409, messageKey: "auth.registration.rejected" },
|
||||
REGISTRATION_REQUEST_INVALID: { httpStatus: 400, messageKey: "auth.registration.request_invalid" },
|
||||
IDEMPOTENCY_KEY_CONFLICT: { httpStatus: 409, messageKey: "request.idempotency_conflict" },
|
||||
AUTH_SESSION_INVALID: { httpStatus: 401, messageKey: "auth.session.invalid" },
|
||||
AUTH_SERVICE_UNAVAILABLE: { httpStatus: 503, messageKey: "auth.service.unavailable" },
|
||||
} as const;
|
||||
|
||||
export type StableEngineeringErrorCode = keyof typeof stableEngineeringErrors;
|
||||
@@ -45,6 +50,11 @@ export const StableEngineeringErrorCodeSchema = Type.Union(
|
||||
Type.Literal("ASSET_HISTORY_REFERENCE_CONFLICT"),
|
||||
Type.Literal("ASSET_CLEANUP_CANDIDATE_STALE"),
|
||||
Type.Literal("STORAGE_CAPACITY_EXCEEDED"),
|
||||
Type.Literal("REGISTRATION_REJECTED"),
|
||||
Type.Literal("REGISTRATION_REQUEST_INVALID"),
|
||||
Type.Literal("IDEMPOTENCY_KEY_CONFLICT"),
|
||||
Type.Literal("AUTH_SESSION_INVALID"),
|
||||
Type.Literal("AUTH_SERVICE_UNAVAILABLE"),
|
||||
],
|
||||
{ $id: "StableEngineeringErrorCode" },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
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}$";
|
||||
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 emailPattern = "^[^@\\s]{1,128}@[^@\\s]{1,190}$";
|
||||
|
||||
export const RegistrationSendRequestSchema = Type.Object(
|
||||
{
|
||||
email: Type.String({ maxLength: 320, pattern: emailPattern }),
|
||||
invite_code: Type.String({ maxLength: 160, minLength: 8 }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "RegistrationSendRequest" },
|
||||
);
|
||||
|
||||
export const RegistrationSendResponseSchema = Type.Object(
|
||||
{
|
||||
challenge_expires_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
registration_id: Type.String({ pattern: uuidPattern }),
|
||||
resend_available_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
status: Type.Literal("verification_sent"),
|
||||
},
|
||||
{ additionalProperties: false, $id: "RegistrationSendResponse" },
|
||||
);
|
||||
|
||||
export const RegistrationCompleteRequestSchema = Type.Object(
|
||||
{
|
||||
creator_name: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
privacy_consent_accepted: Type.Boolean(),
|
||||
privacy_notice_version: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
registration_id: Type.String({ pattern: uuidPattern }),
|
||||
social_id: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
verification_code: Type.String({ pattern: "^[0-9]{6}$" }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "RegistrationCompleteRequest" },
|
||||
);
|
||||
|
||||
export const RegistrationCompleteHeadersSchema = Type.Object(
|
||||
{
|
||||
"idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
},
|
||||
{ additionalProperties: true, $id: "RegistrationCompleteHeaders" },
|
||||
);
|
||||
|
||||
export const AuthenticatedUserSchema = Type.Object(
|
||||
{
|
||||
creator_name: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
role: Type.Literal("user"),
|
||||
social_id: Type.String({ maxLength: 80, minLength: 1 }),
|
||||
status: Type.Literal("active"),
|
||||
user_id: Type.String({ pattern: uuidPattern }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "AuthenticatedUser" },
|
||||
);
|
||||
|
||||
export const CreditSummarySchema = Type.Object(
|
||||
{
|
||||
available_balance: Type.Integer(),
|
||||
reserved_balance: Type.Integer({ minimum: 0 }),
|
||||
},
|
||||
{ additionalProperties: false, $id: "CreditSummary" },
|
||||
);
|
||||
|
||||
export const RegistrationCompleteResponseSchema = Type.Object(
|
||||
{
|
||||
credits: Type.Ref(CreditSummarySchema),
|
||||
session_expires_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
status: Type.Literal("registered"),
|
||||
user: Type.Ref(AuthenticatedUserSchema),
|
||||
},
|
||||
{ additionalProperties: false, $id: "RegistrationCompleteResponse" },
|
||||
);
|
||||
|
||||
export const UserSessionResponseSchema = Type.Object(
|
||||
{
|
||||
audience: Type.Literal("user"),
|
||||
authenticated: Type.Literal(true),
|
||||
credits: Type.Ref(CreditSummarySchema),
|
||||
csrf_token: Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }),
|
||||
expires_at: Type.String({ pattern: isoTimestampPattern }),
|
||||
user: Type.Ref(AuthenticatedUserSchema),
|
||||
},
|
||||
{ additionalProperties: false, $id: "UserSessionResponse" },
|
||||
);
|
||||
|
||||
export type RegistrationSendRequest = Static<typeof RegistrationSendRequestSchema>;
|
||||
export type RegistrationSendResponse = Static<typeof RegistrationSendResponseSchema>;
|
||||
export type RegistrationCompleteRequest = Static<typeof RegistrationCompleteRequestSchema>;
|
||||
export type RegistrationCompleteResponse = Static<typeof RegistrationCompleteResponseSchema>;
|
||||
export type UserSessionResponse = Static<typeof UserSessionResponseSchema>;
|
||||
@@ -1,4 +1,5 @@
|
||||
export { Type } from "@sinclair/typebox";
|
||||
export * from "./api.js";
|
||||
export * from "./auth.js";
|
||||
export * from "./bootstrap.js";
|
||||
export * from "./events.js";
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const phaseIndex = process.argv.indexOf("--phase");
|
||||
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
||||
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
||||
const runId = process.env.DADA_TDD_RUN_ID ?? `wp1-01-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||
const cases = [
|
||||
"TDD-WP1-AUTH-001-valid-register",
|
||||
"TDD-WP1-AUTH-002-invalid-invite",
|
||||
"TDD-WP1-AUTH-001-final-recheck-race",
|
||||
];
|
||||
const directories = Object.fromEntries(cases.map((id) => [id, resolve(runDirectory, "cases", id)]));
|
||||
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||
for (const directory of Object.values(directories)) mkdirSync(directory, { recursive: true });
|
||||
|
||||
const commandSpecs = phase === "red"
|
||||
? [
|
||||
["pnpm exec vitest run tests/integration/wp1-01-registration.test.ts", ["exec", "vitest", "run", "tests/integration/wp1-01-registration.test.ts"]],
|
||||
["pnpm exec vitest run tests/api/wp1-01-registration.test.ts", ["exec", "vitest", "run", "tests/api/wp1-01-registration.test.ts"]],
|
||||
]
|
||||
: [
|
||||
["pnpm test:integration", ["test:integration"]],
|
||||
["pnpm test:api", ["test:api"]],
|
||||
["pnpm test:e2e", ["test:e2e"]],
|
||||
["pnpm validate:tdd-trace", ["validate:tdd-trace"]],
|
||||
];
|
||||
const environment = {
|
||||
...process.env,
|
||||
DADA_EVIDENCE_DIR_AUTH_INVALID: directories[cases[1]],
|
||||
DADA_EVIDENCE_DIR_AUTH_RACE: directories[cases[2]],
|
||||
DADA_EVIDENCE_DIR_AUTH_VALID: directories[cases[0]],
|
||||
};
|
||||
const startedAt = new Date().toISOString();
|
||||
const commands = [];
|
||||
for (const [command, args] of commandSpecs) {
|
||||
const started_at = new Date().toISOString();
|
||||
const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm";
|
||||
const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", `pnpm ${args.join(" ")}`] : args;
|
||||
const execution = spawnSync(executable, actualArgs, { encoding: "utf8", env: environment });
|
||||
if (execution.stdout) process.stdout.write(execution.stdout);
|
||||
if (execution.stderr) process.stderr.write(execution.stderr);
|
||||
commands.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), started_at });
|
||||
}
|
||||
|
||||
const commandEvidence = { commands, phase, run_id: runId, schema_version: "1.0" };
|
||||
for (const directory of Object.values(directories)) {
|
||||
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify(commandEvidence, null, 2)}\n`);
|
||||
}
|
||||
const expectedEvidence = {
|
||||
[cases[0]]: ["request.json", "response.json", "db-diff.json"],
|
||||
[cases[1]]: [
|
||||
"expired-response.json", "disabled-response.json", "exhausted-response.json", "not_found-response.json",
|
||||
"db-diff.json", "external-calls.json",
|
||||
],
|
||||
[cases[2]]: [
|
||||
"exhausted-response.json", "disabled-response.json", "expired-response.json", "stage_limit-response.json",
|
||||
"exhausted-db-diff.json", "disabled-db-diff.json", "expired-db-diff.json", "stage_limit-db-diff.json",
|
||||
"transaction-trace.json",
|
||||
],
|
||||
};
|
||||
const manifest = {
|
||||
path: "tasks.manifest.json",
|
||||
sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(),
|
||||
};
|
||||
const commandState = phase === "red" ? commands.every((item) => item.exit_code !== 0) : commands.every((item) => item.exit_code === 0);
|
||||
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
||||
const worktreeDirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
|
||||
const results = cases.map((testId) => {
|
||||
const evidence_refs = expectedEvidence[testId];
|
||||
const missing_evidence = phase === "green" ? evidence_refs.filter((path) => !existsSync(resolve(directories[testId], path))) : [];
|
||||
const status = phase === "red"
|
||||
? (commandState ? "red_confirmed" : "failed")
|
||||
: (commandState && missing_evidence.length === 0 ? "passed" : "failed");
|
||||
const result = {
|
||||
acceptance_criteria: ["AC-01", "AC-02", "AC-45"],
|
||||
automation: ["automated"],
|
||||
commit,
|
||||
deferred_e2e_evidence: {
|
||||
reason: "Registration UI ownership begins in TASK-WP1-03; no UI or screenshot is fabricated by TASK-WP1-01.",
|
||||
required_by_case: ["trace.zip", "screenshots/"],
|
||||
task_id: "TASK-WP1-03",
|
||||
},
|
||||
environment: { arch: process.arch, node: process.version.slice(1), os: process.platform },
|
||||
evidence_refs,
|
||||
finished_at: new Date().toISOString(),
|
||||
layer: ["DB", "API", "E2E"],
|
||||
manifest,
|
||||
missing_evidence,
|
||||
parent_family: testId.match(/^(TDD-WP[0-7]-[A-Z0-9]+-[0-9]{3})-/)?.[1],
|
||||
phase,
|
||||
release_gate: ["work_package:WP-1", "release:P0-A"],
|
||||
requirements: ["AUTH-01", "AUTH-02", "AUTH-03", "AUTH-04", "NFR-04"],
|
||||
run_id: runId,
|
||||
schema_version: "1.0",
|
||||
started_at: startedAt,
|
||||
status,
|
||||
task_id: "TASK-WP1-01",
|
||||
test_id: testId,
|
||||
work_package: "WP-1",
|
||||
worktree_under_test: worktreeDirty ? "uncommitted implementation" : "clean committed implementation",
|
||||
};
|
||||
writeFileSync(resolve(directories[testId], "result.json"), `${JSON.stringify(result, null, 2)}\n`);
|
||||
return result;
|
||||
});
|
||||
const targetStatus = phase === "red" ? "red_confirmed" : "passed";
|
||||
const passed = results.every((result) => result.status === targetStatus);
|
||||
const summary = {
|
||||
cases: results.map(({ missing_evidence, status, test_id }) => ({ missing_evidence, status, test_id })),
|
||||
phase,
|
||||
run_id: runId,
|
||||
status: passed ? targetStatus : "failed",
|
||||
};
|
||||
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`);
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
if (!passed) process.exit(1);
|
||||
@@ -109,7 +109,23 @@ describe("TDD-WP0-API-001 schema envelope", () => {
|
||||
});
|
||||
|
||||
it("accepts only the frozen error codes and details whitelist", () => {
|
||||
expect(Object.keys(stableEngineeringErrors)).toHaveLength(10);
|
||||
expect(Object.keys(stableEngineeringErrors).sort()).toEqual([
|
||||
"ASSET_CLEANUP_CANDIDATE_STALE",
|
||||
"ASSET_HISTORY_REFERENCE_CONFLICT",
|
||||
"AUTH_SERVICE_UNAVAILABLE",
|
||||
"AUTH_SESSION_INVALID",
|
||||
"BROWSER_UNSUPPORTED",
|
||||
"IDEMPOTENCY_KEY_CONFLICT",
|
||||
"MODEL_CONFIG_VERSION_CONFLICT",
|
||||
"MODEL_DEFAULT_REPLACEMENT_INVALID",
|
||||
"MODEL_DEFAULT_REPLACEMENT_REQUIRED",
|
||||
"MODEL_RECOMMENDATION_PRIORITY_CONFLICT",
|
||||
"MODEL_RECOMMENDATION_PRIORITY_INVALID",
|
||||
"PRIVATE_CONTENT_NOTICE_ACK_REQUIRED",
|
||||
"REGISTRATION_REJECTED",
|
||||
"REGISTRATION_REQUEST_INVALID",
|
||||
"STORAGE_CAPACITY_EXCEEDED",
|
||||
]);
|
||||
|
||||
const envelope = createErrorEnvelope({
|
||||
code: "MODEL_CONFIG_VERSION_CONFLICT",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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";
|
||||
import { isErrorEnvelope } from "../../packages/shared-contracts/src/index.js";
|
||||
|
||||
const fixedNow = Date.parse("2026-07-28T06:00:00.000Z");
|
||||
const writeHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
|
||||
function createRegistrationService() {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp1-01-api-"));
|
||||
roots.push(root);
|
||||
const resend = new MockResendAdapter();
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x41),
|
||||
clock: () => fixedNow,
|
||||
codeGenerator: () => "572914",
|
||||
currentPrivacyNoticeVersion: "p0a-notice-v1",
|
||||
databasePath: join(root, "dada.sqlite3"),
|
||||
inviteCodeGenerator: () => "DADA-WP1-API",
|
||||
invitePepper: Buffer.alloc(32, 0x42),
|
||||
resend,
|
||||
sessionPepper: Buffer.alloc(32, 0x43),
|
||||
});
|
||||
services.push(registration);
|
||||
return { registration, resend };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const service of services.splice(0)) {
|
||||
try { service.close(); } catch { /* already closed by the test */ }
|
||||
}
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("TASK-WP1-01 registration API contract", () => {
|
||||
it("resolves REGISTER_SEND and REGISTER_COMPLETE to one OpenAPI operation each", async () => {
|
||||
const { registration, resend } = createRegistrationService();
|
||||
const invite = registration.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 2 });
|
||||
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
||||
|
||||
const sent = await app.inject({
|
||||
headers: writeHeaders,
|
||||
method: "POST",
|
||||
payload: { email: "api-user@example.invalid", invite_code: invite.code },
|
||||
url: "/api/v1/auth/register/send",
|
||||
});
|
||||
expect(sent.statusCode).toBe(200);
|
||||
expect(sent.json()).toMatchObject({ status: "verification_sent" });
|
||||
|
||||
const completed = await app.inject({
|
||||
headers: { ...writeHeaders, "idempotency-key": "wp1-api-idempotency-key-000000000001" },
|
||||
method: "POST",
|
||||
payload: {
|
||||
creator_name: "API Creator",
|
||||
privacy_consent_accepted: true,
|
||||
privacy_notice_version: "p0a-notice-v1",
|
||||
registration_id: sent.json().registration_id,
|
||||
social_id: "@@api_creator",
|
||||
verification_code: resend.readLatestCode("api-user@example.invalid"),
|
||||
},
|
||||
url: "/api/v1/auth/register/complete",
|
||||
});
|
||||
expect(completed.statusCode).toBe(200);
|
||||
expect(completed.json()).toMatchObject({
|
||||
credits: { available_balance: 10, reserved_balance: 0 },
|
||||
status: "registered",
|
||||
user: { role: "user", social_id: "@api_creator", status: "active" },
|
||||
});
|
||||
expect(completed.headers["set-cookie"]).toContain("dada_session=");
|
||||
expect(completed.headers["set-cookie"]).toContain("HttpOnly");
|
||||
expect(completed.headers["set-cookie"]).toContain("SameSite=Strict");
|
||||
|
||||
const session = await app.inject({
|
||||
headers: { cookie: completed.headers["set-cookie"], host: "127.0.0.1:43121" },
|
||||
method: "GET",
|
||||
url: "/api/v1/auth/session",
|
||||
});
|
||||
expect(session.statusCode).toBe(200);
|
||||
expect(session.json()).toMatchObject({
|
||||
audience: "user",
|
||||
authenticated: true,
|
||||
credits: { available_balance: 10, reserved_balance: 0 },
|
||||
});
|
||||
|
||||
const openapi = app.swagger() as { paths?: Record<string, Record<string, { operationId?: string }>> };
|
||||
const operationIds = Object.values(openapi.paths ?? {}).flatMap((path) =>
|
||||
Object.values(path).map((operation) => operation.operationId),
|
||||
);
|
||||
expect(operationIds.filter((id) => id === "sendRegistrationCode")).toHaveLength(1);
|
||||
expect(operationIds.filter((id) => id === "completeRegistration")).toHaveLength(1);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns a stable field error and does not send mail for an invalid invite", async () => {
|
||||
const { registration, resend } = createRegistrationService();
|
||||
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
|
||||
const response = await app.inject({
|
||||
headers: writeHeaders,
|
||||
method: "POST",
|
||||
payload: { email: "api-rejected@example.invalid", invite_code: "missing-invite" },
|
||||
url: "/api/v1/auth/register/send",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(isErrorEnvelope(response.json())).toBe(true);
|
||||
expect(response.json()).toMatchObject({
|
||||
error: {
|
||||
code: "REGISTRATION_REJECTED",
|
||||
details: { field_errors: [{ field: "invite_code", message_key: "auth.invite.not_found" }] },
|
||||
message_key: "auth.registration.rejected",
|
||||
},
|
||||
});
|
||||
expect(resend.calls).toEqual([]);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
RegistrationError,
|
||||
RegistrationService,
|
||||
type RegistrationTransactionEvent,
|
||||
} from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
|
||||
const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url));
|
||||
const Database = requireFromApi("better-sqlite3");
|
||||
const fixedNow = Date.parse("2026-07-28T06:00:00.000Z");
|
||||
const privacyNoticeVersion = "p0a-notice-v1";
|
||||
const roots: string[] = [];
|
||||
const services: RegistrationService[] = [];
|
||||
|
||||
interface Snapshot {
|
||||
challenges: number;
|
||||
consents: number;
|
||||
credits: number;
|
||||
inviteUsedCount: number;
|
||||
ledger: number;
|
||||
profiles: number;
|
||||
sessions: number;
|
||||
users: number;
|
||||
}
|
||||
|
||||
function createHarness(inviteCode = "DADA-WP1-VALID") {
|
||||
const root = mkdtempSync(join(tmpdir(), "dada-wp1-01-"));
|
||||
roots.push(root);
|
||||
const databasePath = join(root, "dada.sqlite3");
|
||||
const resend = new MockResendAdapter();
|
||||
const transactionEvents: RegistrationTransactionEvent[] = [];
|
||||
const service = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 0x31),
|
||||
clock: () => fixedNow,
|
||||
codeGenerator: () => "482913",
|
||||
currentPrivacyNoticeVersion: privacyNoticeVersion,
|
||||
databasePath,
|
||||
inviteCodeGenerator: () => inviteCode,
|
||||
invitePepper: Buffer.alloc(32, 0x32),
|
||||
onTransaction: (event) => transactionEvents.push(event),
|
||||
resend,
|
||||
sessionPepper: Buffer.alloc(32, 0x33),
|
||||
});
|
||||
services.push(service);
|
||||
return { databasePath, resend, service, transactionEvents };
|
||||
}
|
||||
|
||||
function withDatabase<T>(databasePath: string, operation: (database: any) => T): T {
|
||||
const database = new Database(databasePath);
|
||||
try {
|
||||
return operation(database);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function seedOccupiedSlots(databasePath: string, count: number) {
|
||||
withDatabase(databasePath, (database) => {
|
||||
const insert = database.prepare(`
|
||||
INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||
registration_id, created_at
|
||||
) VALUES (?, ?, 'user', 'active', 1, ?, ?)
|
||||
`);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
insert.run(randomUUID(), `occupied-${index}@example.invalid`, randomUUID(), fixedNow - 1_000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function snapshot(databasePath: string): Snapshot {
|
||||
return withDatabase(databasePath, (database) => ({
|
||||
challenges: database.prepare("SELECT COUNT(*) AS count FROM email_challenges").get().count,
|
||||
consents: database.prepare("SELECT COUNT(*) AS count FROM privacy_consents").get().count,
|
||||
credits: database.prepare("SELECT COUNT(*) AS count FROM credit_accounts").get().count,
|
||||
inviteUsedCount: database.prepare("SELECT COALESCE(SUM(used_count), 0) AS count FROM invite_codes").get().count,
|
||||
ledger: database.prepare("SELECT COUNT(*) AS count FROM credit_ledger").get().count,
|
||||
profiles: database.prepare("SELECT COUNT(*) AS count FROM user_profiles").get().count,
|
||||
sessions: database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count,
|
||||
users: database.prepare("SELECT COUNT(*) AS count FROM users").get().count,
|
||||
}));
|
||||
}
|
||||
|
||||
function writeEvidence(relativeDirectory: string, file: string, value: unknown) {
|
||||
const directory = process.env[relativeDirectory];
|
||||
if (!directory) return;
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
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-WP1-AUTH-001-valid-register", () => {
|
||||
it("commits the account, invite use, initial credit, consent and one 30-day session once", async () => {
|
||||
const harness = createHarness();
|
||||
const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 3 });
|
||||
seedOccupiedSlots(harness.databasePath, 9);
|
||||
const before = snapshot(harness.databasePath);
|
||||
|
||||
const sent = await harness.service.sendRegistrationCode({
|
||||
email: "new-user@example.invalid",
|
||||
inviteCode: invite.code,
|
||||
});
|
||||
const code = harness.resend.readLatestCode("new-user@example.invalid");
|
||||
expect(code).toBe("482913");
|
||||
expect(sent).toMatchObject({ status: "verification_sent" });
|
||||
|
||||
const request = {
|
||||
code,
|
||||
creatorName: "Dada Creator",
|
||||
idempotencyKey: "wp1-valid-register-idempotency-key-0001",
|
||||
privacyConsentAccepted: true,
|
||||
privacyNoticeVersion,
|
||||
registrationId: sent.registrationId,
|
||||
socialId: "@@dada_creator",
|
||||
};
|
||||
const completed = harness.service.completeRegistration(request);
|
||||
const replayed = harness.service.completeRegistration(request);
|
||||
const session = harness.service.readUserSession(completed.sessionToken);
|
||||
const after = snapshot(harness.databasePath);
|
||||
|
||||
expect(completed).toMatchObject({
|
||||
credits: { availableBalance: 10, reservedBalance: 0 },
|
||||
sessionExpiresAt: fixedNow + 30 * 86_400_000,
|
||||
status: "registered",
|
||||
user: { creatorName: "Dada Creator", role: "user", socialId: "@dada_creator", status: "active" },
|
||||
});
|
||||
expect(replayed).toEqual(completed);
|
||||
expect(session).toMatchObject({ audience: "user", userId: completed.user.userId });
|
||||
expect(after).toEqual({
|
||||
challenges: before.challenges + 1,
|
||||
consents: before.consents + 1,
|
||||
credits: before.credits + 1,
|
||||
inviteUsedCount: before.inviteUsedCount + 1,
|
||||
ledger: before.ledger + 1,
|
||||
profiles: before.profiles + 1,
|
||||
sessions: before.sessions + 1,
|
||||
users: before.users + 1,
|
||||
});
|
||||
|
||||
const stored = withDatabase(harness.databasePath, (database) => ({
|
||||
challenge: database.prepare("SELECT code_hmac, consumed_at FROM email_challenges").get(),
|
||||
ledger: database.prepare("SELECT entry_type, amount, available_before, available_after, reserved_before, reserved_after FROM credit_ledger").get(),
|
||||
session: database.prepare("SELECT token_digest, audience, revoked_at FROM sessions").get(),
|
||||
}));
|
||||
expect(stored.challenge.code_hmac).not.toContain(code);
|
||||
expect(stored.challenge.consumed_at).toBe(fixedNow);
|
||||
expect(stored.ledger).toEqual({
|
||||
amount: 10,
|
||||
available_after: 10,
|
||||
available_before: 0,
|
||||
entry_type: "registration_grant",
|
||||
reserved_after: 0,
|
||||
reserved_before: 0,
|
||||
});
|
||||
expect(stored.session).toMatchObject({ audience: "user", revoked_at: null });
|
||||
expect(stored.session.token_digest).not.toBe(completed.sessionToken);
|
||||
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_VALID", "request.json", {
|
||||
operation: ["REGISTER_SEND", "REGISTER_COMPLETE", "AUTH_SESSION"],
|
||||
privacy_notice_version: privacyNoticeVersion,
|
||||
sensitive_fields: "redacted",
|
||||
});
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_VALID", "response.json", {
|
||||
audience: session?.audience,
|
||||
available_balance: completed.credits.availableBalance,
|
||||
reserved_balance: completed.credits.reservedBalance,
|
||||
session_days: 30,
|
||||
status: completed.status,
|
||||
});
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_VALID", "db-diff.json", { after, before, plaintext_secret_fields: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("TDD-WP1-AUTH-002-invalid-invite", () => {
|
||||
it.each(["expired", "disabled", "exhausted", "not_found"] as const)(
|
||||
"rejects %s before creating a challenge or calling Resend",
|
||||
async (scenario) => {
|
||||
const harness = createHarness(`DADA-WP1-${scenario.toUpperCase()}`);
|
||||
const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 1 });
|
||||
withDatabase(harness.databasePath, (database) => {
|
||||
if (scenario === "expired") database.prepare("UPDATE invite_codes SET expires_at = ?").run(fixedNow - 1);
|
||||
if (scenario === "disabled") database.prepare("UPDATE invite_codes SET status = 'disabled'").run();
|
||||
if (scenario === "exhausted") database.prepare("UPDATE invite_codes SET used_count = max_uses").run();
|
||||
});
|
||||
const before = snapshot(harness.databasePath);
|
||||
const expectedReason = scenario === "not_found" ? "invite_not_found" : `invite_${scenario}`;
|
||||
|
||||
await expect(
|
||||
harness.service.sendRegistrationCode({
|
||||
email: "rejected-user@example.invalid",
|
||||
inviteCode: scenario === "not_found" ? `${invite.code}-missing` : invite.code,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "REGISTRATION_REJECTED", reason: expectedReason });
|
||||
|
||||
expect(snapshot(harness.databasePath)).toEqual(before);
|
||||
expect(harness.resend.calls).toEqual([]);
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_INVALID", `${scenario}-response.json`, {
|
||||
code: "REGISTRATION_REJECTED",
|
||||
field: "invite_code",
|
||||
reason: expectedReason,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("writes the aggregate zero-side-effect evidence", () => {
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_INVALID", "db-diff.json", {
|
||||
challenge_delta: 0,
|
||||
consent_delta: 0,
|
||||
credit_delta: 0,
|
||||
invite_used_count_delta: 0,
|
||||
session_delta: 0,
|
||||
user_delta: 0,
|
||||
});
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_INVALID", "external-calls.json", { resend_calls: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("TDD-WP1-AUTH-001-final-recheck-race", () => {
|
||||
it.each(["exhausted", "disabled", "expired", "stage_limit"] as const)(
|
||||
"rechecks %s under BEGIN IMMEDIATE and replays the same failure",
|
||||
async (scenario) => {
|
||||
const harness = createHarness(`DADA-WP1-RACE-${scenario.toUpperCase()}`);
|
||||
const invite = harness.service.createInvite({ expiresAt: fixedNow + 86_400_000, maxUses: 1 });
|
||||
seedOccupiedSlots(harness.databasePath, 9);
|
||||
const sent = await harness.service.sendRegistrationCode({
|
||||
email: "race-user@example.invalid",
|
||||
inviteCode: invite.code,
|
||||
});
|
||||
const code = harness.resend.readLatestCode("race-user@example.invalid");
|
||||
withDatabase(harness.databasePath, (database) => {
|
||||
if (scenario === "exhausted") database.prepare("UPDATE invite_codes SET used_count = max_uses").run();
|
||||
if (scenario === "disabled") database.prepare("UPDATE invite_codes SET status = 'disabled'").run();
|
||||
if (scenario === "expired") database.prepare("UPDATE invite_codes SET expires_at = ?").run(fixedNow - 1);
|
||||
if (scenario === "stage_limit") {
|
||||
database.prepare(`
|
||||
INSERT INTO users (
|
||||
user_id, normalized_email, role, status, counts_toward_stage_limit,
|
||||
registration_id, created_at
|
||||
) VALUES (?, ?, 'user', 'active', 1, ?, ?)
|
||||
`).run(randomUUID(), "tenth-user@example.invalid", randomUUID(), fixedNow);
|
||||
}
|
||||
});
|
||||
const before = snapshot(harness.databasePath);
|
||||
const request = {
|
||||
code,
|
||||
creatorName: "Race User",
|
||||
idempotencyKey: `wp1-race-idempotency-key-${scenario}-0001`,
|
||||
privacyConsentAccepted: true,
|
||||
privacyNoticeVersion,
|
||||
registrationId: sent.registrationId,
|
||||
socialId: "@race_user",
|
||||
};
|
||||
const expectedReason = scenario === "stage_limit" ? "stage_limit_reached" : `invite_${scenario}`;
|
||||
|
||||
let first: RegistrationError | undefined;
|
||||
let second: RegistrationError | undefined;
|
||||
try { harness.service.completeRegistration(request); } catch (error) { first = error as RegistrationError; }
|
||||
try { harness.service.completeRegistration(request); } catch (error) { second = error as RegistrationError; }
|
||||
|
||||
expect(first).toMatchObject({ code: "REGISTRATION_REJECTED", reason: expectedReason });
|
||||
expect(second).toMatchObject({ code: first?.code, reason: first?.reason });
|
||||
expect(snapshot(harness.databasePath)).toEqual(before);
|
||||
expect(harness.transactionEvents.filter((event) => event.operation === "registration_complete"))
|
||||
.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ mode: "BEGIN IMMEDIATE", outcome: "rejected" }),
|
||||
expect.objectContaining({ mode: "BEGIN IMMEDIATE", outcome: "idempotent_replay" }),
|
||||
]));
|
||||
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_RACE", `${scenario}-response.json`, {
|
||||
code: first?.code,
|
||||
reason: first?.reason,
|
||||
retry_code: second?.code,
|
||||
retry_reason: second?.reason,
|
||||
});
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_RACE", `${scenario}-db-diff.json`, { after: snapshot(harness.databasePath), before });
|
||||
},
|
||||
);
|
||||
|
||||
it("writes the aggregate transaction evidence", () => {
|
||||
writeEvidence("DADA_EVIDENCE_DIR_AUTH_RACE", "transaction-trace.json", {
|
||||
business_side_effects: 0,
|
||||
failure_replay: "stable",
|
||||
transaction_mode: "BEGIN IMMEDIATE",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user