feat: add integration tests for initialization flow
- Implemented end-to-end integration tests covering database reset, bootstrap admin creation, demo user provisioning, scenario installation, and general creation. - Added support for deterministic seeding and auto admin general creation. - Created a new package for integration tests with necessary configurations and scripts. - Updated various modules to support new features, including admin user handling and scenario seeding. - Enhanced error handling and validation in the join and orchestrator modules.
This commit is contained in:
@@ -4,7 +4,7 @@ import { randomBytes } from 'node:crypto';
|
||||
|
||||
import type { WorldStateRow } from '../../context.js';
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord, asStringArray, parseBooleanWithFallback } from '@sammo-ts/common';
|
||||
import {
|
||||
isPersonalityTraitKey,
|
||||
isWarTraitKey,
|
||||
@@ -250,13 +250,57 @@ export const joinRouter = router({
|
||||
|
||||
const maxId = await db.general.aggregate({ _max: { id: true } });
|
||||
const nextId = (maxId._max.id ?? 0) + 1;
|
||||
const cityList = await db.city.findMany({ select: { id: true }, orderBy: { id: 'asc' } });
|
||||
const cityList = await db.city.findMany({
|
||||
select: { id: true, level: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (!cityList.length) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '도시 정보를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
// 통합 테스트 전용: ENV로 지정한 경우에만 도시 지정 허용.
|
||||
const allowCityOverride = parseBooleanWithFallback(process.env.INTEGRATION_JOIN_ALLOW_CITY, false);
|
||||
if (allowCityOverride && typeof input.inheritCity === 'number') {
|
||||
const override = cityList.find((city) => city.id === input.inheritCity);
|
||||
if (!override) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '지정한 도시를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
if (override.level !== 5 && override.level !== 6) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '통합 테스트에서는 소성/중성 도시만 지정할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const general = await db.general.create({
|
||||
data: {
|
||||
id: nextId,
|
||||
userId,
|
||||
name: generalName,
|
||||
nationId: 0,
|
||||
cityId: override.id,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
leadership: input.leadership,
|
||||
strength: input.strength,
|
||||
intel: input.intel,
|
||||
personalCode: chosenPersonality ?? 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
turnTime: new Date(),
|
||||
meta: {
|
||||
createdBy: 'join',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true, generalId: general.id };
|
||||
}
|
||||
|
||||
const cityIndex = hashString(userId) % cityList.length;
|
||||
const cityId = cityList[cityIndex]?.id ?? cityList[0].id;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { buildScenarioBootstrap, type ScenarioBootstrapWarning, type WorldSeedPayload } from '@sammo-ts/logic';
|
||||
|
||||
import type { MapLoaderOptions } from './mapLoader.js';
|
||||
@@ -11,6 +12,8 @@ import { loadUnitSetDefinitionByName } from './unitSetLoader.js';
|
||||
const DEFAULT_TICK_SECONDS = 120 * 60;
|
||||
const DEFAULT_GENERAL_GOLD = 1000;
|
||||
const DEFAULT_GENERAL_RICE = 1000;
|
||||
const DEFAULT_OPENING_PART_YEAR = 3;
|
||||
const INTEGRATION_WORLD_SEED_ENV = 'INTEGRATION_WORLD_SEED';
|
||||
|
||||
const MINUTES_TO_MS = 60_000;
|
||||
|
||||
@@ -180,6 +183,18 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
const generalGold = options.defaultGeneralGold ?? DEFAULT_GENERAL_GOLD;
|
||||
const generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE;
|
||||
|
||||
const scenarioConst = asRecord(seed.scenarioConfig.const);
|
||||
if (
|
||||
typeof scenarioConst.openingPartYear !== 'number' ||
|
||||
Number.isNaN(scenarioConst.openingPartYear)
|
||||
) {
|
||||
scenarioConst.openingPartYear = DEFAULT_OPENING_PART_YEAR;
|
||||
}
|
||||
const scenarioConfig = {
|
||||
...seed.scenarioConfig,
|
||||
const: scenarioConst,
|
||||
};
|
||||
|
||||
const worldConfig: Record<string, unknown> = {
|
||||
fiction: install?.fiction,
|
||||
fictionMode: install?.fiction === 0 ? '연의' : install?.fiction === 1 ? '가상' : undefined,
|
||||
@@ -202,6 +217,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
lastTurnTime: formatDateTime(now),
|
||||
};
|
||||
|
||||
const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV];
|
||||
if (typeof integrationSeed === 'string' && integrationSeed.trim().length > 0) {
|
||||
worldMeta.hiddenSeed = integrationSeed.trim();
|
||||
}
|
||||
|
||||
if (install?.preopenAt) {
|
||||
worldMeta.preopenAt = formatDateTime(install.preopenAt);
|
||||
}
|
||||
@@ -233,7 +253,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
currentYear: startState.currentYear,
|
||||
currentMonth: startState.currentMonth,
|
||||
tickSeconds,
|
||||
config: asJson({ ...seed.scenarioConfig, ...worldConfig }),
|
||||
config: asJson({ ...scenarioConfig, ...worldConfig }),
|
||||
meta: asJson(worldMeta),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
|
||||
|
||||
import { procedure, router } from './trpc.js';
|
||||
import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
|
||||
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
||||
@@ -10,6 +13,7 @@ import { toPublicUser } from './auth/userRepository.js';
|
||||
import type { AdminAuthContext } from './adminAuth.js';
|
||||
import type { GatewayApiContext } from './context.js';
|
||||
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
|
||||
import { seedProfileDatabase } from './orchestrator/seedProfileDatabase.js';
|
||||
|
||||
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
||||
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
|
||||
@@ -670,6 +674,7 @@ export const adminRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||
if (!profile) {
|
||||
throw new TRPCError({
|
||||
@@ -779,6 +784,11 @@ export const adminRouter = router({
|
||||
options: autorunUser.options,
|
||||
}
|
||||
: null,
|
||||
adminUser: {
|
||||
id: adminAuth.user.id,
|
||||
username: adminAuth.user.username,
|
||||
displayName: adminAuth.user.displayName,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -807,6 +817,81 @@ export const adminRouter = router({
|
||||
|
||||
return { ok: true, action: actionRecord };
|
||||
}),
|
||||
installNow: profileAdminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
profileName: z.string().min(1),
|
||||
install: zInstallOptions,
|
||||
reason: z.string().max(200).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||
if (!profile) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Profile not found.',
|
||||
});
|
||||
}
|
||||
|
||||
const scenarioValue = String(input.install.scenarioId);
|
||||
let updatedProfile = profile;
|
||||
if (profile.scenario !== scenarioValue) {
|
||||
const updated = await ctx.profiles.updateScenario(profile.profileName, scenarioValue);
|
||||
if (updated) {
|
||||
updatedProfile = updated;
|
||||
}
|
||||
}
|
||||
|
||||
const databaseUrl = resolvePostgresConfigFromEnv({
|
||||
env: process.env,
|
||||
schema: updatedProfile.profile,
|
||||
}).url;
|
||||
const resourcesRoot = path.resolve(process.cwd(), 'resources');
|
||||
|
||||
await seedProfileDatabase({
|
||||
databaseUrl,
|
||||
scenarioId: input.install.scenarioId,
|
||||
tickSeconds: input.install.turnTermMinutes * 60,
|
||||
now: new Date(),
|
||||
installOptions: {
|
||||
turnTermMinutes: input.install.turnTermMinutes,
|
||||
sync: input.install.sync,
|
||||
fiction: input.install.fiction,
|
||||
extend: input.install.extend,
|
||||
blockGeneralCreate: input.install.blockGeneralCreate,
|
||||
npcMode: input.install.npcMode,
|
||||
showImgLevel: input.install.showImgLevel,
|
||||
tournamentTrig: input.install.tournamentTrig,
|
||||
joinMode: input.install.joinMode,
|
||||
autorunUser: input.install.autorunUser
|
||||
? {
|
||||
limitMinutes: input.install.autorunUser.limitMinutes,
|
||||
options: Object.fromEntries(
|
||||
input.install.autorunUser.options.map((option) => [option, true])
|
||||
),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
scenarioOptions: { scenarioRoot: path.join(resourcesRoot, 'scenario') },
|
||||
mapOptions: { mapRoot: path.join(resourcesRoot, 'map') },
|
||||
unitSetOptions: { unitSetRoot: path.join(resourcesRoot, 'unitset') },
|
||||
adminUser: {
|
||||
id: adminAuth.user.id,
|
||||
username: adminAuth.user.username,
|
||||
displayName: adminAuth.user.displayName,
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.profiles.updateStatus(updatedProfile.profileName, 'RUNNING', {
|
||||
preopenAt: null,
|
||||
openAt: null,
|
||||
scheduledStartAt: null,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
requestAction: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine';
|
||||
import { type ScenarioInstallOptions } from '@sammo-ts/game-engine';
|
||||
import { createGamePostgresConnector, resolvePostgresConfigFromEnv } from '@sammo-ts/infra';
|
||||
import { isRecord } from '@sammo-ts/common';
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { BuildRunner } from './buildRunner.js';
|
||||
import type { ProcessManager } from './processManager.js';
|
||||
import type { GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus } from './profileRepository.js';
|
||||
import type { GitWorkspaceManager } from './workspaceManager.js';
|
||||
import { seedProfileDatabase, type AdminSeedUser } from './seedProfileDatabase.js';
|
||||
|
||||
export interface GatewayProcessConfig {
|
||||
workspaceRoot: string;
|
||||
@@ -94,6 +95,11 @@ interface GatewayAdminActionRecord {
|
||||
limitMinutes?: number;
|
||||
options?: string[];
|
||||
} | null;
|
||||
adminUser?: {
|
||||
id?: string;
|
||||
username?: string;
|
||||
displayName?: string | null;
|
||||
};
|
||||
openAt?: string | null;
|
||||
preopenAt?: string | null;
|
||||
gitRef?: string | null;
|
||||
@@ -146,11 +152,12 @@ const parseInstallOptions = (
|
||||
): {
|
||||
installOptions: ScenarioInstallOptions | null;
|
||||
scenarioId: number | null;
|
||||
adminUser: AdminSeedUser | null;
|
||||
openAt: Date | null;
|
||||
preopenAt: Date | null;
|
||||
} => {
|
||||
if (!isRecord(action.install)) {
|
||||
return { installOptions: null, scenarioId: null, openAt: null, preopenAt: null };
|
||||
return { installOptions: null, scenarioId: null, adminUser: null, openAt: null, preopenAt: null };
|
||||
}
|
||||
|
||||
const install = action.install;
|
||||
@@ -196,6 +203,20 @@ const parseInstallOptions = (
|
||||
|
||||
const openAt = parseDateTime(install.openAt ?? null);
|
||||
const preopenAt = parseDateTime(install.preopenAt ?? null);
|
||||
const adminUser =
|
||||
isRecord(install.adminUser) && typeof install.adminUser.id === 'string'
|
||||
? {
|
||||
id: install.adminUser.id,
|
||||
username:
|
||||
typeof install.adminUser.username === 'string'
|
||||
? install.adminUser.username
|
||||
: install.adminUser.id,
|
||||
displayName:
|
||||
typeof install.adminUser.displayName === 'string'
|
||||
? install.adminUser.displayName
|
||||
: undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
const installOptions: ScenarioInstallOptions = {
|
||||
turnTermMinutes,
|
||||
@@ -214,6 +235,7 @@ const parseInstallOptions = (
|
||||
return {
|
||||
installOptions,
|
||||
scenarioId,
|
||||
adminUser,
|
||||
openAt,
|
||||
preopenAt,
|
||||
};
|
||||
@@ -582,7 +604,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
this.buildInFlight = true;
|
||||
this.resetInFlight.add(profile.profileName);
|
||||
try {
|
||||
const { installOptions, scenarioId: installScenarioId, openAt, preopenAt } = parseInstallOptions(action);
|
||||
const { installOptions, scenarioId: installScenarioId, adminUser, openAt, preopenAt } =
|
||||
parseInstallOptions(action);
|
||||
const tickOverride =
|
||||
installOptions?.turnTermMinutes !== undefined ? installOptions.turnTermMinutes * 60 : undefined;
|
||||
const seedInfo = await this.resolveResetSeedInfo(profile, {
|
||||
@@ -622,7 +645,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
}
|
||||
const workspace = await this.workspaceManager.prepare(commitSha);
|
||||
const resourceRoot = path.join(workspace.root, 'resources');
|
||||
await seedScenarioToDatabase({
|
||||
await seedProfileDatabase({
|
||||
databaseUrl: seedInfo.databaseUrl,
|
||||
scenarioId: seedInfo.scenarioId,
|
||||
tickSeconds: seedInfo.tickSeconds,
|
||||
@@ -631,6 +654,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
scenarioOptions: { scenarioRoot: path.join(resourceRoot, 'scenario') },
|
||||
mapOptions: { mapRoot: path.join(resourceRoot, 'map') },
|
||||
unitSetOptions: { unitSetRoot: path.join(resourceRoot, 'unitset') },
|
||||
adminUser,
|
||||
});
|
||||
await this.repository.updateBuildStatus(profile.profileName, 'SUCCEEDED', {
|
||||
completedAt,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine';
|
||||
import { createGamePostgresConnector } from '@sammo-ts/infra';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
export interface AdminSeedUser {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName?: string | null;
|
||||
}
|
||||
|
||||
export interface SeedProfileDatabaseOptions {
|
||||
databaseUrl: string;
|
||||
scenarioId: number;
|
||||
tickSeconds?: number;
|
||||
now?: Date;
|
||||
installOptions?: ScenarioInstallOptions;
|
||||
scenarioOptions?: Parameters<typeof seedScenarioToDatabase>[0]['scenarioOptions'];
|
||||
mapOptions?: Parameters<typeof seedScenarioToDatabase>[0]['mapOptions'];
|
||||
unitSetOptions?: Parameters<typeof seedScenarioToDatabase>[0]['unitSetOptions'];
|
||||
adminUser?: AdminSeedUser | null;
|
||||
}
|
||||
|
||||
const DEFAULT_STAT_TOTAL = 165;
|
||||
const DEFAULT_STAT_MIN = 15;
|
||||
const DEFAULT_STAT_MAX = 80;
|
||||
|
||||
const hashString = (value: string): number => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
const resolveAdminStats = (config: Record<string, unknown>) => {
|
||||
const stat = asRecord(config.stat);
|
||||
const total = typeof stat.total === 'number' && Number.isFinite(stat.total) ? stat.total : DEFAULT_STAT_TOTAL;
|
||||
const min = typeof stat.min === 'number' && Number.isFinite(stat.min) ? stat.min : DEFAULT_STAT_MIN;
|
||||
const max = typeof stat.max === 'number' && Number.isFinite(stat.max) ? stat.max : DEFAULT_STAT_MAX;
|
||||
|
||||
const stats = [min, min, min];
|
||||
let remaining = Math.max(0, total - min * 3);
|
||||
let guard = 0;
|
||||
while (remaining > 0 && guard < 1000) {
|
||||
let progressed = false;
|
||||
for (let i = 0; i < stats.length && remaining > 0; i += 1) {
|
||||
if (stats[i] >= max) {
|
||||
continue;
|
||||
}
|
||||
stats[i] += 1;
|
||||
remaining -= 1;
|
||||
progressed = true;
|
||||
}
|
||||
if (!progressed) {
|
||||
break;
|
||||
}
|
||||
guard += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
leadership: stats[0],
|
||||
strength: stats[1],
|
||||
intelligence: stats[2],
|
||||
};
|
||||
};
|
||||
|
||||
const resolveAdminName = async (
|
||||
prisma: Awaited<ReturnType<typeof createGamePostgresConnector>>['prisma'],
|
||||
adminUser: AdminSeedUser
|
||||
): Promise<string> => {
|
||||
const base = (adminUser.displayName ?? adminUser.username ?? '').trim() || '관리자';
|
||||
const existing = await prisma.general.findFirst({ where: { name: base } });
|
||||
if (!existing) {
|
||||
return base;
|
||||
}
|
||||
const suffix = adminUser.id.replace(/[^a-zA-Z0-9]/g, '').slice(0, 4) || 'admin';
|
||||
for (let i = 1; i <= 5; i += 1) {
|
||||
const candidate = `${base}_${suffix}${i}`;
|
||||
const found = await prisma.general.findFirst({ where: { name: candidate } });
|
||||
if (!found) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return `${base}_${suffix}${Date.now().toString(36)}`;
|
||||
};
|
||||
|
||||
// 초기 설치/통합 테스트에서 관리자 장수를 자동 생성한다.
|
||||
const ensureAdminGeneral = async (databaseUrl: string, adminUser: AdminSeedUser): Promise<void> => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl });
|
||||
await connector.connect();
|
||||
try {
|
||||
const prisma = connector.prisma;
|
||||
const existing = await prisma.general.findFirst({ where: { userId: adminUser.id } });
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const worldState = await prisma.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cityRows = await prisma.city.findMany({
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const cityId =
|
||||
cityRows.length > 0 ? cityRows[hashString(adminUser.id) % cityRows.length]!.id : 0;
|
||||
|
||||
const maxId = await prisma.general.aggregate({ _max: { id: true } });
|
||||
const nextId = (maxId._max.id ?? 0) + 1;
|
||||
const stats = resolveAdminStats(asRecord(worldState.config));
|
||||
const name = await resolveAdminName(prisma, adminUser);
|
||||
const meta = asRecord(worldState.meta);
|
||||
const rawTurnTime = typeof meta.turntime === 'string' ? new Date(meta.turntime) : null;
|
||||
const turnTime =
|
||||
rawTurnTime && !Number.isNaN(rawTurnTime.getTime()) ? rawTurnTime : new Date();
|
||||
|
||||
await prisma.general.create({
|
||||
data: {
|
||||
id: nextId,
|
||||
userId: adminUser.id,
|
||||
name,
|
||||
nationId: 0,
|
||||
cityId,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
leadership: stats.leadership,
|
||||
strength: stats.strength,
|
||||
intel: stats.intelligence,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
turnTime,
|
||||
meta: {
|
||||
createdBy: 'admin-seed',
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await connector.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
// 시나리오 시드 후 관리자 장수를 포함한 초기 데이터를 준비한다.
|
||||
export const seedProfileDatabase = async (options: SeedProfileDatabaseOptions) => {
|
||||
const result = await seedScenarioToDatabase({
|
||||
scenarioId: options.scenarioId,
|
||||
databaseUrl: options.databaseUrl,
|
||||
tickSeconds: options.tickSeconds,
|
||||
now: options.now,
|
||||
installOptions: options.installOptions,
|
||||
scenarioOptions: options.scenarioOptions,
|
||||
mapOptions: options.mapOptions,
|
||||
unitSetOptions: options.unitSetOptions,
|
||||
});
|
||||
|
||||
if (options.adminUser) {
|
||||
await ensureAdminGeneral(options.databaseUrl, options.adminUser);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -15,6 +15,7 @@ const zUsername = z.string().min(2).max(32);
|
||||
const zPassword = z.string().min(6).max(128);
|
||||
const zProfile = z.string().min(1).max(64);
|
||||
const zOAuthMode = z.enum(['login', 'change_pw']);
|
||||
const zBootstrapToken = z.string().min(1);
|
||||
|
||||
const parseDate = (value: string): Date | null => {
|
||||
const parsed = parseISO(value);
|
||||
@@ -61,6 +62,51 @@ export const appRouter = router({
|
||||
}),
|
||||
admin: adminRouter,
|
||||
auth: router({
|
||||
bootstrapLocal: procedure
|
||||
.input(
|
||||
z.object({
|
||||
token: zBootstrapToken,
|
||||
username: zUsername,
|
||||
password: zPassword,
|
||||
displayName: z.string().min(2).max(40).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const expected = process.env.GATEWAY_BOOTSTRAP_TOKEN ?? '';
|
||||
if (!expected) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Bootstrap is disabled.',
|
||||
});
|
||||
}
|
||||
if (input.token !== expected) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid bootstrap token.',
|
||||
});
|
||||
}
|
||||
const existing = await ctx.prisma.appUser.findFirst({
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: 'Bootstrap is already completed.',
|
||||
});
|
||||
}
|
||||
const created = await ctx.users.createUser({
|
||||
username: input.username,
|
||||
password: input.password,
|
||||
displayName: input.displayName,
|
||||
});
|
||||
await ctx.users.updateRoles(created.id, ['superuser']);
|
||||
const session = await ctx.sessions.createSession(created);
|
||||
return {
|
||||
user: toPublicUser(created),
|
||||
sessionToken: session.sessionToken,
|
||||
issuedAt: session.issuedAt,
|
||||
};
|
||||
}),
|
||||
kakaoStart: procedure
|
||||
.input(
|
||||
z
|
||||
|
||||
Reference in New Issue
Block a user