1import { isValidName, VAULT_SLOTS } from "../../../shared/mod.ts";2import type { Ttl, VaultLifecycle } from "../../../shared/mod.ts";3import {4 createVault,5 defaultVaults,6 listVaults,7 setDefaultVault,8 updateVault,9 vaultRowToInfo,10 type VaultSecurityPatch,11} from "../db.ts";12import { type Env, json } from "../env.ts";13import { ApiError, readJson } from "./errors.ts";14import { passwordHashFor } from "./common.ts";1516const DESCRIPTION_MAX_LENGTH = 500;1718interface SecurityInput {19 maxTtl?: unknown;20 requirePassword?: unknown;21 defaultPassword?: unknown;22}2324async function securityPatch(body: SecurityInput): Promise<VaultSecurityPatch> {25 const patch: VaultSecurityPatch = {};26 if (body.maxTtl !== undefined) {27 if (28 body.maxTtl !== null && (typeof body.maxTtl !== "number" ||29 !Number.isFinite(body.maxTtl) || body.maxTtl <= 0 || body.maxTtl > 3650)30 ) {31 throw new ApiError(400, "maxTtl must be null or greater than 0 and at most 3650 days");32 }33 patch.maxTtl = body.maxTtl;34 }35 if (body.requirePassword !== undefined) {36 if (typeof body.requirePassword !== "boolean") {37 throw new ApiError(400, "requirePassword must be boolean");38 }39 patch.requirePassword = body.requirePassword;40 }41 if (body.defaultPassword !== undefined) {42 patch.defaultPasswordHash = await passwordHashFor(body.defaultPassword);43 }44 return patch;45}4647function descriptionValue(value: unknown): string | null {48 if (value === null) return null;49 if (typeof value !== "string") {50 throw new ApiError(400, "description must be a string or null");51 }52 const description = value.trim();53 if (description.length > DESCRIPTION_MAX_LENGTH) {54 throw new ApiError(55 400,56 `description must be at most ${DESCRIPTION_MAX_LENGTH} characters`,57 );58 }59 // Descriptions are rendered raw into CLI tables; keep them single-line.60 if (/[\x00-\x1f\x7f-\x9f]/.test(description)) {61 throw new ApiError(400, "description must not contain control characters");62 }63 return description || null;64}6566function defaultTtlValue(value: unknown): Ttl | null {67 if (value === null || value === "forever") return value;68 if (69 typeof value !== "number" || !Number.isFinite(value) || value <= 0 ||70 value > 365071 ) {72 throw new ApiError(73 400,74 'defaultTtl must be null, 1-3650 days, or "forever"',75 );76 }77 return value;78}7980function lifecycleValue(value: unknown): VaultLifecycle | null {81 if (value === null || value === "temporary" || value === "permanent") {82 return value;83 }84 throw new ApiError(85 400,86 'defaultFor must be null, "temporary", or "permanent"',87 );88}8990export async function handleVaults(91 request: Request,92 env: Env,93): Promise<Response> {94 if (request.method === "GET") return json(await listVaults(env));95 const body = await readJson<96 {97 name: string;98 slot?: number;99 description?: unknown;100 defaultTtl?: unknown;101 defaultFor?: unknown;102 } & SecurityInput103 >(request);104 if (!isValidName(body.name)) throw new ApiError(400, "invalid vault name");105 if (body.slot !== undefined && (body.slot < 0 || body.slot >= VAULT_SLOTS)) {106 throw new ApiError(400, "slot must be 0-15");107 }108 const description = body.description === undefined ? null : descriptionValue(body.description);109 const defaultTtl = body.defaultTtl === undefined ? null : defaultTtlValue(body.defaultTtl);110 const defaultFor = body.defaultFor === undefined ? null : lifecycleValue(body.defaultFor);111 const security = await securityPatch(body);112 try {113 const vault = await createVault(114 env,115 body.name,116 body.slot,117 description,118 defaultTtl,119 defaultFor,120 security,121 );122 return json(123 vaultRowToInfo(vault, 0, defaultFor === null ? [] : [defaultFor]),124 201,125 );126 } catch (cause) {127 const message = cause instanceof Error ? cause.message : "vault conflict";128 throw new ApiError(409, message, { cause });129 }130}131132export async function handleVault(133 request: Request,134 env: Env,135 encodedName: string,136): Promise<Response> {137 if (request.method !== "PATCH") throw new ApiError(405, "method not allowed");138 let currentName: string;139 try {140 currentName = decodeURIComponent(encodedName);141 } catch {142 throw new ApiError(400, "invalid vault name");143 }144 const body = await readJson<145 {146 name?: unknown;147 description?: unknown;148 defaultTtl?: unknown;149 defaultFor?: unknown;150 } & SecurityInput151 >(request);152 if (153 body.name === undefined && body.description === undefined &&154 body.defaultTtl === undefined && body.defaultFor === undefined &&155 body.maxTtl === undefined && body.requirePassword === undefined &&156 body.defaultPassword === undefined157 ) {158 throw new ApiError(159 400,160 "provide name, description, defaultTtl, defaultFor, maxTtl, requirePassword, or defaultPassword",161 );162 }163 if (164 body.name !== undefined &&165 (typeof body.name !== "string" || !isValidName(body.name))166 ) {167 throw new ApiError(400, "invalid vault name");168 }169 const patch = {170 ...await securityPatch(body),171 ...(typeof body.name === "string" ? { name: body.name } : {}),172 ...(body.description !== undefined ? { description: descriptionValue(body.description) } : {}),173 ...(body.defaultTtl !== undefined ? { defaultTtl: defaultTtlValue(body.defaultTtl) } : {}),174 ...(body.defaultFor !== undefined ? { defaultFor: lifecycleValue(body.defaultFor) } : {}),175 };176 try {177 const vault = await updateVault(env, currentName, patch);178 if (!vault) throw new ApiError(404, `unknown vault: ${currentName}`);179 return json(vault);180 } catch (cause) {181 if (cause instanceof ApiError) throw cause;182 const message = cause instanceof Error ? cause.message : "vault conflict";183 throw new ApiError(409, message, { cause });184 }185}186187export async function handleDefaultVault(188 request: Request,189 env: Env,190 lifecycleSegment: string,191): Promise<Response> {192 const lifecycle = lifecycleValue(lifecycleSegment);193 if (lifecycle === null) throw new ApiError(400, "lifecycle is required");194 const body = await readJson<{ name?: unknown }>(request);195 if (typeof body.name !== "string" || !isValidName(body.name)) {196 throw new ApiError(400, "valid vault name required");197 }198 const vault = await setDefaultVault(env, lifecycle, body.name);199 if (!vault) throw new ApiError(404, `unknown vault: ${body.name}`);200 return json({ defaultVaults: await defaultVaults(env), vault });201}