server be & fe

This commit is contained in:
azykov@mail.ru
2026-09-29 13:50:01 +03:00
commit 657009bd6c
56 changed files with 6162 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Copy to .env and fill in.
# 32 random bytes in base64, e.g.: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Losing it makes all stored access keys unreadable.
KEY_ENCRYPTION_SECRET=
# Created (or promoted) as superadmin on start.
SUPERADMIN_EMAIL=
# Group of that superadmin if it has to be created.
SUPERADMIN_GROUP=default
# The reverse proxy strips /api and sends X-Forwarded-Prefix: /api.
# public /api/admin/* (Authelia) -> /admin/*
# public /api/* (no auth) -> /*
# Public API URL for key QR codes. Optional: derived from X-Forwarded-Proto/Host/Prefix when empty.
# PUBLIC_API_URL=https://dev.aggtaa.com/api
# Optional extra protection: when set, /admin/* requires the header X-Cardy-Proxy-Secret with this value.
# Configure the reverse proxy to add it on the /api/admin route. Recommended if HOST is not loopback.
# ADMIN_PROXY_SECRET=
DATA_DIR=./data
# Loopback by default: /admin/* trusts Remote-Email, so only the local reverse proxy may connect.
HOST=127.0.0.1
PORT=10002
+25
View File
@@ -0,0 +1,25 @@
import { build } from 'esbuild';
import path from 'node:path';
// Bundle workspace packages (they ship TypeScript sources), keep npm dependencies external.
const externalNpmPackages = {
name: 'external-npm-packages',
setup(b) {
b.onResolve({ filter: /.*/ }, (args) => {
if (args.kind === 'entry-point' || args.path.startsWith('.') || path.isAbsolute(args.path)) return undefined;
if (args.path.startsWith('@cardy/')) return undefined;
return { path: args.path, external: true };
});
},
};
await build({
entryPoints: ['src/index.ts'],
outfile: 'dist/index.js',
bundle: true,
platform: 'node',
format: 'esm',
target: 'node22',
sourcemap: true,
plugins: [externalNpmPackages],
});
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@cardy/server-be",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
"build": "node build.mjs",
"start": "node --env-file-if-exists=.env dist/index.js",
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"@cardy/shared": "workspace:*",
"@fastify/cookie": "^11.1.2",
"@fastify/cors": "^11.3.0",
"@fastify/multipart": "^10.1.2",
"fastify": "^5.12.5",
"yaml": "^2.9.1",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/node": "^22.20.4",
"esbuild": "^0.28.2",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
}
+50
View File
@@ -0,0 +1,50 @@
import cookie from '@fastify/cookie';
import multipart from '@fastify/multipart';
import Fastify, { type FastifyServerOptions } from 'fastify';
import { ZodError } from 'zod';
import { CardsService } from './cards.js';
import { ADMIN_API_PREFIX, type Config } from './config.js';
import { HttpError } from './errors.js';
import { formatZodError } from './http.js';
import { adminApi } from './routes/adminApi.js';
import { clientApi } from './routes/clientApi.js';
import { UsersService } from './users.js';
// Upload size is limited by the reverse proxy, not by the server.
const NO_LIMIT = Number.MAX_SAFE_INTEGER;
export async function buildApp(config: Config, options: FastifyServerOptions = {}) {
// trustProxy: protocol and host come from X-Forwarded-* set by the reverse proxy.
const app = Fastify({ bodyLimit: NO_LIMIT, trustProxy: true, ...options });
const users = new UsersService(config.dataDir, config.keySecret);
const cards = new CardsService(config.dataDir);
await users.ensureSuperadmin(config.superadminEmail, config.superadminGroup);
app.setErrorHandler((error, request, reply) => {
if (error instanceof HttpError) return reply.code(error.statusCode).send({ error: error.message });
if (error instanceof ZodError) return reply.code(400).send({ error: formatZodError(error) });
const statusCode = (error as { statusCode?: number }).statusCode;
if (statusCode && statusCode >= 400 && statusCode < 500) {
return reply.code(statusCode).send({ error: (error as Error).message });
}
request.log.error(error);
return reply.code(500).send({ error: 'Internal server error' });
});
app.setNotFoundHandler((_request, reply) => reply.code(404).send({ error: 'Not found' }));
await app.register(cookie);
await app.register(multipart, { limits: { fileSize: NO_LIMIT, files: NO_LIMIT, fieldSize: 1024 * 1024 } });
await app.register(adminApi, {
prefix: ADMIN_API_PREFIX,
users,
cards,
proxySecret: config.adminProxySecret,
publicApiUrl: config.publicApiUrl,
});
await app.register(clientApi, { users, cards });
return app;
}
+240
View File
@@ -0,0 +1,240 @@
import { randomUUID } from 'node:crypto';
import { mkdir, rm, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
import { CODE_TYPES, type Card, type CardFields, type ImageRef } from '@cardy/shared';
import { badRequest, notFound } from './errors.js';
import { YamlFile } from './yamlFile.js';
const safeId = z.string().regex(/^[A-Za-z0-9-]+$/, 'Invalid id');
const safeFileName = z.string().regex(/^[A-Za-z0-9-]+\.[A-Za-z0-9]{1,10}$/, 'Invalid file name');
const imageRefSchema = z.object({ id: safeId, file: safeFileName, mime: z.string() });
const cardSchema = z.object({
id: safeId,
name: z.string(),
color: z.string(),
code: z.coerce.string(),
codeType: z.enum(CODE_TYPES),
logo: imageRefSchema.nullable().default(null),
images: z.array(imageRefSchema).default([]),
imagesVersion: z.number().int().default(0),
deleted: z.boolean().default(false),
createdAt: z.string(),
updatedAt: z.string(),
});
const cardsFileSchema = z.object({ cards: z.array(cardSchema).default([]) });
type CardsFile = z.infer<typeof cardsFileSchema>;
export interface Upload {
data: Buffer;
mime: string;
filename?: string;
}
export interface CardAccess {
/** Admin UI sees deleted cards; the client API does not. */
includeDeleted: boolean;
}
const MIME_EXTENSIONS: Record<string, string> = {
'image/webp': 'webp',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/avif': 'avif',
'image/heic': 'heic',
'image/heif': 'heif',
'image/bmp': 'bmp',
'image/svg+xml': 'svg',
};
function extensionFor(upload: Upload): string {
const byMime = MIME_EXTENSIONS[upload.mime.toLowerCase()];
if (byMime) return byMime;
const byName = path.extname(upload.filename ?? '').slice(1).toLowerCase();
return /^[a-z0-9]{1,10}$/.test(byName) ? byName : 'bin';
}
export class CardsService {
private readonly files = new Map<string, YamlFile<CardsFile>>();
constructor(private readonly dataDir: string) {}
private groupDir(groupId: string): string {
return path.join(this.dataDir, 'groups', safeId.parse(groupId));
}
private imageDir(groupId: string, cardId: string): string {
return path.join(this.groupDir(groupId), 'images', safeId.parse(cardId));
}
private file(groupId: string): YamlFile<CardsFile> {
let file = this.files.get(groupId);
if (!file) {
file = new YamlFile(
path.join(this.groupDir(groupId), 'cards.yaml'),
(raw) => cardsFileSchema.parse(raw ?? {}),
() => ({ cards: [] }),
);
this.files.set(groupId, file);
}
return file;
}
async list(groupId: string, access: CardAccess): Promise<Card[]> {
const { cards } = await this.file(groupId).read();
return access.includeDeleted ? cards : cards.filter((c) => !c.deleted);
}
async get(groupId: string, cardId: string, access: CardAccess): Promise<Card> {
const card = (await this.file(groupId).read()).cards.find((c) => c.id === cardId);
if (!card || (card.deleted && !access.includeDeleted)) throw notFound('Card not found');
return card;
}
/** Path of the card's logo or image file on disk. */
async imagePath(groupId: string, cardId: string, imageId: 'logo' | string, access: CardAccess) {
const card = await this.get(groupId, cardId, access);
const ref = imageId === 'logo' ? card.logo : card.images.find((i) => i.id === imageId);
if (!ref) throw notFound('Image not found');
return { path: path.join(this.imageDir(groupId, card.id), ref.file), mime: ref.mime };
}
async create(groupId: string, fields: CardFields, logo?: Upload): Promise<Card> {
const id = randomUUID();
const now = new Date().toISOString();
const logoRef = logo ? await this.saveFile(groupId, id, logo) : null;
const card: Card = {
id,
...fields,
logo: logoRef,
images: [],
imagesVersion: logoRef ? 1 : 0,
deleted: false,
createdAt: now,
updatedAt: now,
};
try {
await this.file(groupId).update((d) => {
d.cards.push(card);
});
} catch (err) {
await rm(this.imageDir(groupId, id), { recursive: true, force: true });
throw err;
}
return card;
}
/**
* Updates fields and optionally the logo (`logo: null` removes it).
* The client API cannot touch deleted cards nor change the `deleted` flag.
*/
async update(
groupId: string,
cardId: string,
patch: Partial<CardFields> & { deleted?: boolean },
access: CardAccess,
logo?: Upload | null,
): Promise<Card> {
if (patch.deleted !== undefined && !access.includeDeleted) throw badRequest('Cannot change deleted flag');
await this.get(groupId, cardId, access);
const newLogo = logo ? await this.saveFile(groupId, cardId, logo) : undefined;
let oldLogo = null as ImageRef | null;
let card: Card;
try {
card = await this.mutate(groupId, cardId, access, (c) => {
Object.assign(c, stripUndefined(patch));
if (logo !== undefined) {
oldLogo = c.logo;
c.logo = newLogo ?? null;
c.imagesVersion++;
}
});
} catch (err) {
if (newLogo) await this.removeFiles(groupId, cardId, [newLogo]);
throw err;
}
if (oldLogo) await this.removeFiles(groupId, cardId, [oldLogo]);
return card;
}
async setDeleted(groupId: string, cardId: string, deleted: boolean): Promise<Card> {
return this.mutate(groupId, cardId, { includeDeleted: true }, (c) => {
c.deleted = deleted;
});
}
/** Permanently removes the card and its files. */
async purge(groupId: string, cardId: string): Promise<void> {
await this.file(groupId).update((d) => {
const before = d.cards.length;
d.cards = d.cards.filter((c) => c.id !== cardId);
if (d.cards.length === before) throw notFound('Card not found');
});
await rm(this.imageDir(groupId, cardId), { recursive: true, force: true });
}
async addImages(groupId: string, cardId: string, uploads: Upload[], access: CardAccess): Promise<Card> {
if (uploads.length === 0) throw badRequest('No files uploaded');
await this.get(groupId, cardId, access);
const refs: ImageRef[] = [];
try {
for (const upload of uploads) refs.push(await this.saveFile(groupId, cardId, upload));
return await this.mutate(groupId, cardId, access, (c) => {
c.images.push(...refs);
c.imagesVersion++;
});
} catch (err) {
await this.removeFiles(groupId, cardId, refs);
throw err;
}
}
async removeImage(groupId: string, cardId: string, imageId: string, access: CardAccess): Promise<Card> {
let removed: ImageRef | undefined;
const card = await this.mutate(groupId, cardId, access, (c) => {
removed = c.images.find((i) => i.id === imageId);
if (!removed) throw notFound('Image not found');
c.images = c.images.filter((i) => i !== removed);
c.imagesVersion++;
});
if (removed) await this.removeFiles(groupId, cardId, [removed]);
return card;
}
async removeGroup(groupId: string): Promise<void> {
this.files.delete(groupId);
await rm(this.groupDir(groupId), { recursive: true, force: true });
}
private mutate(groupId: string, cardId: string, access: CardAccess, fn: (card: Card) => void): Promise<Card> {
return this.file(groupId).update((d) => {
const card = d.cards.find((c) => c.id === cardId);
if (!card || (card.deleted && !access.includeDeleted)) throw notFound('Card not found');
fn(card);
card.updatedAt = new Date().toISOString();
return card;
});
}
private async saveFile(groupId: string, cardId: string, upload: Upload): Promise<ImageRef> {
const id = randomUUID();
const file = `${id}.${extensionFor(upload)}`;
const dir = this.imageDir(groupId, cardId);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, file), upload.data);
return { id, file, mime: upload.mime || 'application/octet-stream' };
}
private async removeFiles(groupId: string, cardId: string, refs: ImageRef[]): Promise<void> {
const dir = this.imageDir(groupId, cardId);
await Promise.all(refs.map((r) => unlink(path.join(dir, r.file)).catch(() => undefined)));
}
}
function stripUndefined<T extends object>(obj: T): Partial<T> {
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial<T>;
}
+61
View File
@@ -0,0 +1,61 @@
import path from 'node:path';
export interface Config {
dataDir: string;
keySecret: Buffer;
superadminEmail: string;
superadminGroup: string;
/**
* Public base URL of the API for key QR codes, e.g. https://dev.aggtaa.com/api.
* When unset it is derived per request from X-Forwarded-Proto/Host/Prefix.
*/
publicApiUrl?: string;
/** When set, admin API requests must carry it in X-Cardy-Proxy-Secret (added by the reverse proxy). */
adminProxySecret?: string;
host: string;
port: number;
}
/**
* The reverse proxy strips "/api": public /api/admin/* (behind Authelia) arrives here as /admin/*,
* the rest of /api/* (client API, no Authelia) arrives at the root.
*/
export const ADMIN_API_PREFIX = '/admin';
export function parseKeySecret(value: string | undefined): Buffer {
if (!value) throw new Error('KEY_ENCRYPTION_SECRET is required');
const buf = Buffer.from(value, 'base64');
if (buf.length !== 32 || buf.toString('base64') !== value.trim()) {
throw new Error('KEY_ENCRYPTION_SECRET must be exactly 32 bytes encoded in base64');
}
return buf;
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const superadminEmail = env.SUPERADMIN_EMAIL?.trim().toLowerCase();
if (!superadminEmail) throw new Error('SUPERADMIN_EMAIL is required');
const publicApiUrl = env.PUBLIC_API_URL?.trim().replace(/\/+$/, '') || undefined;
if (publicApiUrl) {
try {
new URL(publicApiUrl);
} catch {
throw new Error(`PUBLIC_API_URL is not a valid URL: ${publicApiUrl}`);
}
}
const port = Number(env.PORT ?? 10002);
if (!Number.isInteger(port) || port <= 0) throw new Error(`Invalid PORT: ${env.PORT}`);
return {
dataDir: path.resolve(env.DATA_DIR ?? './data'),
keySecret: parseKeySecret(env.KEY_ENCRYPTION_SECRET),
superadminEmail,
superadminGroup: env.SUPERADMIN_GROUP?.trim() || 'default',
publicApiUrl,
adminProxySecret: env.ADMIN_PROXY_SECRET?.trim() || undefined,
// Loopback by default: the admin API trusts Remote-Email, so only the local reverse proxy may connect.
host: env.HOST ?? '127.0.0.1',
port,
};
}
+29
View File
@@ -0,0 +1,29 @@
import { createCipheriv, createDecipheriv, randomBytes, randomInt } from 'node:crypto';
const KEY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
export const ACCESS_KEY_LENGTH = 32;
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
export function generateAccessKey(): string {
let key = '';
for (let i = 0; i < ACCESS_KEY_LENGTH; i++) key += KEY_ALPHABET[randomInt(KEY_ALPHABET.length)];
return key;
}
/** AES-256-GCM; result is base64(iv | authTag | ciphertext). */
export function encryptSecret(plain: string, secret: Buffer): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv('aes-256-gcm', secret, iv);
const ciphertext = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64');
}
export function decryptSecret(encrypted: string, secret: Buffer): string {
const buf = Buffer.from(encrypted, 'base64');
if (buf.length < IV_LENGTH + TAG_LENGTH) throw new Error('Encrypted value is too short');
const decipher = createDecipheriv('aes-256-gcm', secret, buf.subarray(0, IV_LENGTH));
decipher.setAuthTag(buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH));
return Buffer.concat([decipher.update(buf.subarray(IV_LENGTH + TAG_LENGTH)), decipher.final()]).toString('utf8');
}
+14
View File
@@ -0,0 +1,14 @@
export class HttpError extends Error {
constructor(
readonly statusCode: number,
message: string,
) {
super(message);
}
}
export const badRequest = (message: string) => new HttpError(400, message);
export const unauthorized = (message = 'Unauthorized') => new HttpError(401, message);
export const forbidden = (message = 'Forbidden') => new HttpError(403, message);
export const notFound = (message = 'Not found') => new HttpError(404, message);
export const conflict = (message: string) => new HttpError(409, message);
+18
View File
@@ -0,0 +1,18 @@
import type { FastifyRequest } from 'fastify';
/**
* Path prefix the reverse proxy stripped before forwarding (X-Forwarded-Prefix), e.g. "/api".
* Returns '' when absent or malformed.
*/
export function forwardedPrefix(request: FastifyRequest): string {
const raw = request.headers['x-forwarded-prefix'];
const value = (Array.isArray(raw) ? raw[0] : raw)?.split(',')[0]?.trim().replace(/\/+$/, '') ?? '';
return /^(\/[A-Za-z0-9._~-]+)*$/.test(value) ? value : '';
}
/** Public base URL of this server as seen by clients, e.g. https://dev.aggtaa.com/api */
export function publicBaseUrl(request: FastifyRequest, override?: string): string {
if (override) return override;
// With trustProxy, protocol/host come from X-Forwarded-Proto / X-Forwarded-Host.
return `${request.protocol}://${request.host}${forwardedPrefix(request)}`;
}
+56
View File
@@ -0,0 +1,56 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { ZodError, type ZodType } from 'zod';
import type { Upload } from './cards.js';
import { badRequest, notFound } from './errors.js';
export function parseBody<T>(schema: ZodType<T>, value: unknown): T {
const result = schema.safeParse(value ?? {});
if (!result.success) throw badRequest(formatZodError(result.error));
return result.data;
}
export function formatZodError(error: ZodError): string {
return error.issues.map((i) => (i.path.length ? `${i.path.join('.')}: ${i.message}` : i.message)).join('; ');
}
export interface MultipartData {
fields: Record<string, string>;
files: Map<string, Upload[]>;
}
export async function readMultipart(request: FastifyRequest): Promise<MultipartData> {
if (!request.isMultipart()) throw badRequest('Expected multipart/form-data');
const fields: Record<string, string> = {};
const files = new Map<string, Upload[]>();
for await (const part of request.parts()) {
if (part.type === 'file') {
const data = await part.toBuffer();
if (data.length === 0 && !part.filename) continue; // empty <input type=file>
const list = files.get(part.fieldname) ?? [];
list.push({ data, mime: part.mimetype, filename: part.filename });
files.set(part.fieldname, list);
} else {
fields[part.fieldname] = String(part.value);
}
}
return { fields, files };
}
/** Sends a stored user upload; non-images are forced to download and nothing is ever rendered as active content. */
export async function sendImage(reply: FastifyReply, file: { path: string; mime: string }) {
let size: number;
try {
size = (await stat(file.path)).size;
} catch {
throw notFound('Image file is missing');
}
return reply
.header('Content-Type', file.mime.toLowerCase().startsWith('image/') ? file.mime : 'application/octet-stream')
.header('Content-Length', size)
.header('X-Content-Type-Options', 'nosniff')
.header('Content-Security-Policy', 'sandbox')
.header('Cache-Control', 'private, no-cache')
.send(createReadStream(file.path));
}
+23
View File
@@ -0,0 +1,23 @@
import { buildApp } from './app.js';
import { loadConfig } from './config.js';
const config = loadConfig();
const app = await buildApp(config, { logger: true });
app.log.info(
{ dataDir: config.dataDir, publicApiUrl: config.publicApiUrl ?? '(from X-Forwarded-* headers)' },
'Cardy server configuration',
);
if (!config.adminProxySecret && !['127.0.0.1', '::1', 'localhost'].includes(config.host)) {
app.log.warn(
`Listening on ${config.host} without ADMIN_PROXY_SECRET: anyone who can reach this port can impersonate users via Remote-Email`,
);
}
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => {
app.close().then(() => process.exit(0));
});
}
await app.listen({ host: config.host, port: config.port });
+241
View File
@@ -0,0 +1,241 @@
import { timingSafeEqual } from 'node:crypto';
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import {
cardFieldsSchema,
cardPatchSchema,
currentGroupSchema,
groupInputSchema,
keyCreateSchema,
userCreateSchema,
userPatchSchema,
type Group,
type MeResponse,
} from '@cardy/shared';
import type { CardsService } from '../cards.js';
import { ADMIN_API_PREFIX } from '../config.js';
import { forwardedPrefix, publicBaseUrl } from '../forwarded.js';
import { badRequest, forbidden, notFound, unauthorized } from '../errors.js';
import { parseBody, readMultipart, sendImage } from '../http.js';
import { requireGroupAdmin, requireSuperadmin, type StoredUser, type UsersService } from '../users.js';
export const GROUP_COOKIE = 'cardy_group';
export const CSRF_HEADER = 'x-cardy-admin';
export const PROXY_SECRET_HEADER = 'x-cardy-proxy-secret';
export interface AdminContext {
user: StoredUser;
ownGroup: Group;
currentGroup: Group;
}
declare module 'fastify' {
interface FastifyRequest {
admin: AdminContext;
}
}
interface Options {
users: UsersService;
cards: CardsService;
proxySecret?: string;
publicApiUrl?: string;
}
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
const ADMIN_ACCESS = { includeDeleted: true };
type IdParams = { id: string };
type ImageParams = { id: string; imageId: string };
type EmailParams = { email: string };
type KeyParams = { email: string; keyId: string };
export const adminApi: FastifyPluginAsync<Options> = async (app, { users, cards, proxySecret, publicApiUrl }) => {
app.decorateRequest('admin', null as unknown as AdminContext);
const apiUrl = (request: FastifyRequest) => publicBaseUrl(request, publicApiUrl);
app.addHook('onRequest', async (request) => {
// Only the reverse proxy route that goes through forward-auth may reach the admin API.
if (proxySecret) {
const given = request.headers[PROXY_SECRET_HEADER];
if (typeof given !== 'string' || !safeEqual(given, proxySecret)) {
throw unauthorized(`Missing or wrong ${PROXY_SECRET_HEADER} header (ADMIN_PROXY_SECRET is set on the server)`);
}
}
// Header-based CSRF guard: cross-site forms cannot set custom headers.
if (request.method !== 'GET' && request.method !== 'HEAD' && request.headers[CSRF_HEADER] !== '1') {
throw forbidden(`Missing ${CSRF_HEADER} header`);
}
const email = request.headers['remote-email'];
if (typeof email !== 'string' || !email.trim()) throw unauthorized('Missing Remote-Email header from the authentication proxy');
const user = await users.findUser(email);
if (!user) throw unauthorized(`User ${email.trim().toLowerCase()} is not registered in Cardy. Ask an administrator for access.`);
const ownGroup = await users.getGroup(user.groupId);
if (!ownGroup) throw unauthorized('User group does not exist');
let currentGroup = ownGroup;
const cookieGroupId = request.cookies[GROUP_COOKIE];
if (user.role === 'superadmin' && cookieGroupId && cookieGroupId !== ownGroup.id) {
currentGroup = (await users.getGroup(cookieGroupId)) ?? ownGroup;
}
request.admin = { user, ownGroup, currentGroup };
});
// Cookie path as the browser sees it, e.g. /api/admin.
const setGroupCookie = (request: FastifyRequest, reply: FastifyReply, groupId: string) =>
reply.setCookie(GROUP_COOKIE, groupId, {
path: forwardedPrefix(request) + ADMIN_API_PREFIX,
httpOnly: true,
sameSite: 'strict',
secure: request.protocol === 'https',
maxAge: 60 * 60 * 24 * 365,
});
const groupAdmin = (request: FastifyRequest) => {
const { user, currentGroup } = request.admin;
requireGroupAdmin(user, currentGroup.id);
return { user, groupId: currentGroup.id };
};
// ---- current user ----
app.get('/me', async (request, reply): Promise<MeResponse> => {
const { user, ownGroup, currentGroup } = request.admin;
if (request.cookies[GROUP_COOKIE] !== currentGroup.id) setGroupCookie(request, reply, currentGroup.id);
return { email: user.email, role: user.role, group: ownGroup, currentGroup };
});
app.get('/my-keys', async (request) => users.keyViews(request.admin.user, apiUrl(request)));
// ---- groups ----
app.get('/groups', async (request) => {
requireSuperadmin(request.admin.user);
return users.listGroups();
});
app.post('/groups', async (request) => {
const { name } = parseBody(groupInputSchema, request.body);
return users.createGroup(request.admin.user, name);
});
app.patch<{ Params: IdParams }>('/groups/:id', async (request) => {
const { name } = parseBody(groupInputSchema, request.body);
return users.renameGroup(request.admin.user, request.params.id, name);
});
app.delete<{ Params: IdParams }>('/groups/:id', async (request, reply) => {
await users.deleteGroup(request.admin.user, request.params.id);
await cards.removeGroup(request.params.id);
return reply.code(204).send();
});
app.put('/current-group', async (request, reply) => {
requireSuperadmin(request.admin.user);
const { groupId } = parseBody(currentGroupSchema, request.body);
const group = await users.getGroup(groupId);
if (!group) throw notFound('Group not found');
setGroupCookie(request, reply, group.id);
return group;
});
// ---- users of the current group ----
app.get('/users', async (request) => {
const { user, groupId } = groupAdmin(request);
return (await users.listUsers(user, groupId)).map((u) => users.userView(u, apiUrl(request)));
});
app.post('/users', async (request, reply) => {
const { user, groupId } = groupAdmin(request);
const created = await users.createUser(user, groupId, parseBody(userCreateSchema, request.body));
return reply.code(201).send(users.userView(created, apiUrl(request)));
});
app.patch<{ Params: EmailParams }>('/users/:email', async (request) => {
const { user, groupId } = groupAdmin(request);
const updated = await users.updateUser(user, groupId, request.params.email, parseBody(userPatchSchema, request.body));
return users.userView(updated, apiUrl(request));
});
app.delete<{ Params: EmailParams }>('/users/:email', async (request, reply) => {
const { user, groupId } = groupAdmin(request);
await users.deleteUser(user, groupId, request.params.email);
return reply.code(204).send();
});
app.post<{ Params: EmailParams }>('/users/:email/keys', async (request, reply) => {
const { user, groupId } = groupAdmin(request);
const { label } = parseBody(keyCreateSchema, request.body);
const key = await users.createKey(user, groupId, request.params.email, label || undefined, apiUrl(request));
return reply.code(201).send(key);
});
app.delete<{ Params: KeyParams }>('/users/:email/keys/:keyId', async (request, reply) => {
const { user, groupId } = groupAdmin(request);
await users.deleteKey(user, groupId, request.params.email, request.params.keyId);
return reply.code(204).send();
});
// ---- cards of the current group ----
app.get('/cards', async (request) => cards.list(groupAdmin(request).groupId, ADMIN_ACCESS));
app.post('/cards', async (request, reply) => {
const { groupId } = groupAdmin(request);
return reply.code(201).send(await cards.create(groupId, parseBody(cardFieldsSchema, request.body)));
});
app.patch<{ Params: IdParams }>('/cards/:id', async (request) => {
const { groupId } = groupAdmin(request);
return cards.update(groupId, request.params.id, parseBody(cardPatchSchema, request.body), ADMIN_ACCESS);
});
/** First delete marks the card as deleted, deleting an already deleted card removes it permanently. */
app.delete<{ Params: IdParams }>('/cards/:id', async (request, reply) => {
const { groupId } = groupAdmin(request);
const card = await cards.get(groupId, request.params.id, ADMIN_ACCESS);
if (!card.deleted) return cards.setDeleted(groupId, card.id, true);
await cards.purge(groupId, card.id);
return reply.code(204).send();
});
app.get<{ Params: IdParams }>('/cards/:id/logo', async (request, reply) => {
const { groupId } = groupAdmin(request);
return sendImage(reply, await cards.imagePath(groupId, request.params.id, 'logo', ADMIN_ACCESS));
});
app.put<{ Params: IdParams }>('/cards/:id/logo', async (request) => {
const { groupId } = groupAdmin(request);
const { files } = await readMultipart(request);
const logo = files.get('file')?.[0];
if (!logo) throw badRequest('No file uploaded');
return cards.update(groupId, request.params.id, {}, ADMIN_ACCESS, logo);
});
app.delete<{ Params: IdParams }>('/cards/:id/logo', async (request) => {
const { groupId } = groupAdmin(request);
return cards.update(groupId, request.params.id, {}, ADMIN_ACCESS, null);
});
app.get<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request, reply) => {
const { groupId } = groupAdmin(request);
const { id, imageId } = request.params;
return sendImage(reply, await cards.imagePath(groupId, id, imageId, ADMIN_ACCESS));
});
app.post<{ Params: IdParams }>('/cards/:id/images', async (request) => {
const { groupId } = groupAdmin(request);
const { files } = await readMultipart(request);
return cards.addImages(groupId, request.params.id, files.get('file') ?? [], ADMIN_ACCESS);
});
app.delete<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request) => {
const { groupId } = groupAdmin(request);
return cards.removeImage(groupId, request.params.id, request.params.imageId, ADMIN_ACCESS);
});
};
+95
View File
@@ -0,0 +1,95 @@
import cors, { type FastifyCorsOptions } from '@fastify/cors';
import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
import { cardFieldsSchema, type ApiAuthResponse } from '@cardy/shared';
import type { CardsService } from '../cards.js';
import { unauthorized } from '../errors.js';
import { parseBody, readMultipart, sendImage } from '../http.js';
import type { KeyOwner, UsersService } from '../users.js';
declare module 'fastify' {
interface FastifyRequest {
keyOwner: KeyOwner;
}
}
interface Options {
users: UsersService;
cards: CardsService;
}
const CLIENT_ACCESS = { includeDeleted: false };
type IdParams = { id: string };
type ImageParams = { id: string; imageId: string };
export const clientApi: FastifyPluginAsync<Options> = async (app, { users, cards }) => {
// Capacitor apps run on origin https://localhost; auth is a bearer key, never cookies.
// The plugin's catch-all OPTIONS route would also match admin paths, so CORS is off there.
const corsOptions: FastifyCorsOptions = {
origin: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
maxAge: 86400,
};
await app.register(cors, () => (request: FastifyRequest, callback: (err: Error | null, options: FastifyCorsOptions) => void) => {
const isAdmin = /^\/admin(\/|\?|$)/.test(request.url ?? '');
callback(null, isAdmin ? { origin: false } : corsOptions);
});
app.decorateRequest('keyOwner', null as unknown as KeyOwner);
app.addHook('preHandler', async (request) => {
if (request.method === 'OPTIONS') return;
const match = /^Bearer\s+(\S+)\s*$/i.exec(request.headers.authorization ?? '');
const owner = match?.[1] ? await users.authenticateKey(match[1]) : undefined;
if (!owner) throw unauthorized('Invalid access key');
request.keyOwner = owner;
});
const groupId = (request: { keyOwner: KeyOwner }) => request.keyOwner.group.id;
app.get('/auth', async (request): Promise<ApiAuthResponse> => {
const { user, group } = request.keyOwner;
return { email: user.email, group };
});
app.get('/cards', async (request) => cards.list(groupId(request), CLIENT_ACCESS));
app.post('/cards', async (request, reply) => {
const { fields, files } = await readMultipart(request);
const card = await cards.create(groupId(request), parseBody(cardFieldsSchema, fields), files.get('logo')?.[0]);
return reply.code(201).send(card);
});
app.put<{ Params: IdParams }>('/cards/:id', async (request) => {
const { fields, files } = await readMultipart(request);
const { removeLogo, ...cardFields } = fields;
const patch = parseBody(cardFieldsSchema.partial(), cardFields);
const logo = files.get('logo')?.[0] ?? (removeLogo === 'true' ? null : undefined);
return cards.update(groupId(request), request.params.id, patch, CLIENT_ACCESS, logo);
});
app.delete<{ Params: IdParams }>('/cards/:id', async (request, reply) => {
const card = await cards.get(groupId(request), request.params.id, CLIENT_ACCESS);
await cards.setDeleted(groupId(request), card.id, true);
return reply.code(204).send();
});
app.get<{ Params: IdParams }>('/cards/:id/logo', async (request, reply) =>
sendImage(reply, await cards.imagePath(groupId(request), request.params.id, 'logo', CLIENT_ACCESS)),
);
app.get<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request, reply) => {
const { id, imageId } = request.params;
return sendImage(reply, await cards.imagePath(groupId(request), id, imageId, CLIENT_ACCESS));
});
app.post<{ Params: IdParams }>('/cards/:id/images', async (request) => {
const { files } = await readMultipart(request);
return cards.addImages(groupId(request), request.params.id, files.get('file') ?? [], CLIENT_ACCESS);
});
app.delete<{ Params: ImageParams }>('/cards/:id/images/:imageId', async (request) =>
cards.removeImage(groupId(request), request.params.id, request.params.imageId, CLIENT_ACCESS),
);
};
+279
View File
@@ -0,0 +1,279 @@
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { z } from 'zod';
import { ROLES, buildLoginDeepLink, type AccessKeyView, type Group, type Role, type UserView } from '@cardy/shared';
import { decryptSecret, encryptSecret, generateAccessKey } from './crypto.js';
import { badRequest, conflict, forbidden, notFound } from './errors.js';
import { YamlFile } from './yamlFile.js';
const idSchema = z.string().regex(/^[A-Za-z0-9-]+$/, 'Invalid id');
const storedKeySchema = z.object({
id: idSchema,
label: z.string().optional(),
createdAt: z.string(),
encrypted: z.string(),
});
const storedUserSchema = z.object({
email: z.string().transform((s) => s.trim().toLowerCase()),
groupId: idSchema,
role: z.enum(ROLES),
keys: z.array(storedKeySchema).default([]),
});
const usersFileSchema = z.object({
groups: z.array(z.object({ id: idSchema, name: z.string() })).default([]),
users: z.array(storedUserSchema).default([]),
});
export type StoredKey = z.infer<typeof storedKeySchema>;
export type StoredUser = z.infer<typeof storedUserSchema>;
type UsersFile = z.infer<typeof usersFileSchema>;
export interface KeyOwner {
user: StoredUser;
group: Group;
}
export class UsersService {
private readonly file: YamlFile<UsersFile>;
/** Plaintext key → key owner email; rebuilt when the file data object changes. */
private keyIndex: { source: UsersFile; map: Map<string, string> } | undefined;
constructor(
dataDir: string,
private readonly secret: Buffer,
) {
this.file = new YamlFile(
path.join(dataDir, 'users.yaml'),
(raw) => usersFileSchema.parse(raw ?? {}),
() => ({ groups: [], users: [] }),
);
}
/** Creates the configured superadmin (and their group) if missing, and enforces the superadmin role. */
async ensureSuperadmin(email: string, groupName: string): Promise<void> {
const data = await this.file.read();
const existing = data.users.find((u) => u.email === email);
if (existing?.role === 'superadmin') return;
await this.file.update((d) => {
const user = d.users.find((u) => u.email === email);
if (user) {
user.role = 'superadmin';
return;
}
let group = d.groups.find((g) => sameName(g.name, groupName));
if (!group) {
group = { id: randomUUID(), name: groupName };
d.groups.push(group);
}
d.users.push({ email, groupId: group.id, role: 'superadmin', keys: [] });
});
}
// ---- lookups ----
async findUser(email: string): Promise<StoredUser | undefined> {
const normalized = email.trim().toLowerCase();
return (await this.file.read()).users.find((u) => u.email === normalized);
}
async getGroup(id: string): Promise<Group | undefined> {
return (await this.file.read()).groups.find((g) => g.id === id);
}
async listGroups(): Promise<Group[]> {
return [...(await this.file.read()).groups].sort((a, b) => a.name.localeCompare(b.name));
}
async authenticateKey(key: string): Promise<KeyOwner | undefined> {
const data = await this.file.read();
if (this.keyIndex?.source !== data) {
const map = new Map<string, string>();
for (const user of data.users) {
for (const k of user.keys) map.set(decryptSecret(k.encrypted, this.secret), user.email);
}
this.keyIndex = { source: data, map };
}
const email = this.keyIndex.map.get(key);
const user = email ? data.users.find((u) => u.email === email) : undefined;
const group = user ? data.groups.find((g) => g.id === user.groupId) : undefined;
return user && group ? { user, group } : undefined;
}
// ---- views ----
/** `apiUrl` is the public API base URL that goes into login deep links. */
keyViews(user: StoredUser, apiUrl: string): AccessKeyView[] {
return user.keys.map((k) => {
const key = decryptSecret(k.encrypted, this.secret);
return { id: k.id, label: k.label, createdAt: k.createdAt, key, deepLink: buildLoginDeepLink(apiUrl, key) };
});
}
userView(user: StoredUser, apiUrl: string): UserView {
return { email: user.email, groupId: user.groupId, role: user.role, keys: this.keyViews(user, apiUrl) };
}
// ---- groups (superadmin) ----
async createGroup(actor: StoredUser, name: string): Promise<Group> {
requireSuperadmin(actor);
return this.file.update((d) => {
if (d.groups.some((g) => sameName(g.name, name))) throw conflict(`Group "${name}" already exists`);
const group = { id: randomUUID(), name };
d.groups.push(group);
return group;
});
}
async renameGroup(actor: StoredUser, id: string, name: string): Promise<Group> {
requireSuperadmin(actor);
return this.file.update((d) => {
const group = d.groups.find((g) => g.id === id);
if (!group) throw notFound('Group not found');
if (d.groups.some((g) => g.id !== id && sameName(g.name, name))) throw conflict(`Group "${name}" already exists`);
group.name = name;
return group;
});
}
/** Removes the group with its users and keys. The caller removes the group's cards. */
async deleteGroup(actor: StoredUser, id: string): Promise<void> {
requireSuperadmin(actor);
await this.file.update((d) => {
if (!d.groups.some((g) => g.id === id)) throw notFound('Group not found');
if (d.users.some((u) => u.groupId === id && u.role === 'superadmin')) {
throw conflict('A group that contains a superadmin cannot be deleted');
}
d.groups = d.groups.filter((g) => g.id !== id);
d.users = d.users.filter((u) => u.groupId !== id);
});
}
// ---- users (admin of the current group) ----
async listUsers(actor: StoredUser, groupId: string): Promise<StoredUser[]> {
requireGroupAdmin(actor, groupId);
const data = await this.file.read();
return data.users.filter((u) => u.groupId === groupId).sort((a, b) => a.email.localeCompare(b.email));
}
async createUser(
actor: StoredUser,
currentGroupId: string,
input: { email: string; role: Role; groupId?: string },
): Promise<StoredUser> {
const groupId = input.groupId ?? currentGroupId;
if (groupId !== currentGroupId) requireSuperadmin(actor);
requireGroupAdmin(actor, groupId);
if (input.role === 'superadmin') requireSuperadmin(actor);
return this.file.update((d) => {
if (!d.groups.some((g) => g.id === groupId)) throw notFound('Group not found');
if (d.users.some((u) => u.email === input.email)) throw conflict(`User ${input.email} already exists`);
const user: StoredUser = { email: input.email, groupId, role: input.role, keys: [] };
d.users.push(user);
return user;
});
}
async updateUser(
actor: StoredUser,
currentGroupId: string,
email: string,
patch: { role?: Role; groupId?: string },
): Promise<StoredUser> {
if (patch.role === 'superadmin') requireSuperadmin(actor);
if (patch.groupId !== undefined) requireSuperadmin(actor);
return this.file.update((d) => {
const user = findManagedUser(d, actor, currentGroupId, email);
if (patch.role !== undefined && patch.role !== user.role) {
if (user.email === actor.email) throw badRequest('You cannot change your own role');
if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot demote the last superadmin');
user.role = patch.role;
}
if (patch.groupId !== undefined && patch.groupId !== user.groupId) {
if (!d.groups.some((g) => g.id === patch.groupId)) throw notFound('Group not found');
user.groupId = patch.groupId;
}
return user;
});
}
async deleteUser(actor: StoredUser, currentGroupId: string, email: string): Promise<void> {
await this.file.update((d) => {
const user = findManagedUser(d, actor, currentGroupId, email);
if (user.email === actor.email) throw badRequest('You cannot delete yourself');
if (user.role === 'superadmin' && countSuperadmins(d) === 1) throw badRequest('Cannot delete the last superadmin');
d.users = d.users.filter((u) => u !== user);
});
}
// ---- keys ----
async createKey(
actor: StoredUser,
currentGroupId: string,
email: string,
label: string | undefined,
apiUrl: string,
): Promise<AccessKeyView> {
const plain = generateAccessKey();
const stored: StoredKey = {
id: randomUUID(),
...(label ? { label } : {}),
createdAt: new Date().toISOString(),
encrypted: encryptSecret(plain, this.secret),
};
await this.file.update((d) => {
findManagedUser(d, actor, currentGroupId, email).keys.push(stored);
});
return {
id: stored.id,
label: stored.label,
createdAt: stored.createdAt,
key: plain,
deepLink: buildLoginDeepLink(apiUrl, plain),
};
}
async deleteKey(actor: StoredUser, currentGroupId: string, email: string, keyId: string): Promise<void> {
await this.file.update((d) => {
const user = findManagedUser(d, actor, currentGroupId, email);
const before = user.keys.length;
user.keys = user.keys.filter((k) => k.id !== keyId);
if (user.keys.length === before) throw notFound('Key not found');
});
}
}
function sameName(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase();
}
function countSuperadmins(d: UsersFile): number {
return d.users.filter((u) => u.role === 'superadmin').length;
}
export function isGroupAdmin(actor: StoredUser, groupId: string): boolean {
return actor.role === 'superadmin' || (actor.role === 'admin' && actor.groupId === groupId);
}
export function requireGroupAdmin(actor: StoredUser, groupId: string): void {
if (!isGroupAdmin(actor, groupId)) throw forbidden();
}
export function requireSuperadmin(actor: StoredUser): void {
if (actor.role !== 'superadmin') throw forbidden();
}
/** A user of the current group that the actor may manage (only superadmins manage superadmins). */
function findManagedUser(d: UsersFile, actor: StoredUser, currentGroupId: string, email: string): StoredUser {
requireGroupAdmin(actor, currentGroupId);
const normalized = email.trim().toLowerCase();
const user = d.users.find((u) => u.email === normalized && u.groupId === currentGroupId);
if (!user) throw notFound('User not found');
if (user.role === 'superadmin') requireSuperadmin(actor);
return user;
}
+81
View File
@@ -0,0 +1,81 @@
import { randomBytes } from 'node:crypto';
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import YAML from 'yaml';
/**
* A YAML document on disk with an in-memory cache.
* - Reads are served from cache until the file's mtime changes (manual edits are picked up).
* - Updates are serialized and written atomically (temp file + rename).
* - Callers must treat data returned by read() as immutable.
*/
export class YamlFile<T> {
private cache: { mtimeMs: number; data: T } | undefined;
private queue: Promise<unknown> = Promise.resolve();
constructor(
readonly filePath: string,
private readonly parse: (raw: unknown) => T,
private readonly empty: () => T,
) {}
async read(): Promise<T> {
let mtimeMs: number;
try {
mtimeMs = (await stat(this.filePath)).mtimeMs;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return this.empty();
throw err;
}
if (this.cache?.mtimeMs === mtimeMs) return this.cache.data;
const text = await readFile(this.filePath, 'utf8');
let data: T;
try {
data = this.parse(YAML.parse(text) ?? undefined);
} catch (err) {
throw new Error(`Invalid data in ${this.filePath}: ${(err as Error).message}`);
}
this.cache = { mtimeMs, data };
return data;
}
/** Runs `fn` on a copy of the data and saves the result; if `fn` throws, nothing is written. */
update<R>(fn: (data: T) => R | Promise<R>): Promise<R> {
const run = async () => {
const data = structuredClone(await this.read());
const result = await fn(data);
await this.write(data);
return result;
};
const next = this.queue.then(run, run);
this.queue = next.catch(() => undefined);
return next;
}
private async write(data: T): Promise<void> {
await mkdir(path.dirname(this.filePath), { recursive: true });
const tmp = `${this.filePath}.${randomBytes(6).toString('hex')}.tmp`;
await writeFile(tmp, YAML.stringify(data), 'utf8');
try {
await renameWithRetry(tmp, this.filePath);
} catch (err) {
await unlink(tmp).catch(() => undefined);
throw err;
}
this.cache = { mtimeMs: (await stat(this.filePath)).mtimeMs, data };
}
}
// On Windows a rename over a file that is momentarily open elsewhere fails with EPERM/EBUSY.
async function renameWithRetry(from: string, to: string): Promise<void> {
for (let attempt = 0; ; attempt++) {
try {
await rename(from, to);
return;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (attempt >= 5 || (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES')) throw err;
await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)));
}
}
}
+232
View File
@@ -0,0 +1,232 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import type { FastifyInstance } from 'fastify';
import { describe, expect, it } from 'vitest';
import type { AccessKeyView, Card, Group, MeResponse, UserView } from '@cardy/shared';
import { SUPERADMIN, multipart, testApp } from './helpers.js';
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
function admin(app: FastifyInstance, email: string, cookie?: string) {
return async (method: Method, url: string, body?: unknown) => {
const res = await app.inject({
method,
url: `/admin${url}`,
headers: { 'remote-email': email, 'x-cardy-admin': '1', ...(cookie ? { cookie } : {}) },
...(body !== undefined ? { payload: body as object } : {}),
});
const isJson = String(res.headers['content-type'] ?? '').startsWith('application/json');
return { status: res.statusCode, body: isJson ? res.json() : undefined, res };
};
}
async function setup() {
const { app, config } = await testApp();
const root = admin(app, SUPERADMIN);
const me = (await root('GET', '/me')).body as MeResponse;
return { app, config, root, defaultGroup: me.group };
}
describe('admin API authentication', () => {
it('bootstraps the superadmin from config', async () => {
const { root } = await setup();
const { status, body } = await root('GET', '/me');
expect(status).toBe(200);
expect(body).toMatchObject({ email: SUPERADMIN, role: 'superadmin', group: { name: 'default' } });
});
it('returns 401 without header or for unknown users', async () => {
const { app } = await setup();
expect((await app.inject({ url: '/admin/me' })).statusCode).toBe(401);
expect((await admin(app, 'nobody@example.com')('GET', '/me')).status).toBe(401);
});
it('matches Remote-Email case-insensitively', async () => {
const { app } = await setup();
expect((await admin(app, SUPERADMIN.toUpperCase())('GET', '/me')).status).toBe(200);
});
it('requires the CSRF header on mutations', async () => {
const { app } = await setup();
const res = await app.inject({
method: 'POST',
url: '/admin/groups',
headers: { 'remote-email': SUPERADMIN },
payload: { name: 'x' },
});
expect(res.statusCode).toBe(403);
});
});
describe('groups and current group', () => {
it('lets the superadmin switch groups via cookie', async () => {
const { app, root, defaultGroup } = await setup();
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
expect((await root('POST', '/groups', { name: 'other' })).status).toBe(409);
const switched = await root('PUT', '/current-group', { groupId: other.id });
expect(switched.status).toBe(200);
const setCookie = String(switched.res.headers['set-cookie']);
expect(setCookie).toContain(`cardy_group=${other.id}`);
expect(setCookie).toMatch(/HttpOnly/i);
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
expect(((await inOther('GET', '/me')).body as MeResponse).currentGroup.id).toBe(other.id);
// Unknown group in cookie falls back to own group.
const stale = admin(app, SUPERADMIN, 'cardy_group=missing');
expect(((await stale('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
});
it('ignores the group cookie for non-superadmins', async () => {
const { app, root, defaultGroup } = await setup();
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
await root('POST', '/users', { email: 'adm@example.com', role: 'admin' });
const adm = admin(app, 'adm@example.com', `cardy_group=${other.id}`);
expect(((await adm('GET', '/me')).body as MeResponse).currentGroup.id).toBe(defaultGroup.id);
expect((await adm('PUT', '/current-group', { groupId: other.id })).status).toBe(403);
expect((await adm('GET', '/groups')).status).toBe(403);
});
it('refuses to delete a group with a superadmin, deletes others with users and cards', async () => {
const { app, config, root, defaultGroup } = await setup();
expect((await root('DELETE', `/groups/${defaultGroup.id}`)).status).toBe(409);
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
await inOther('POST', '/users', { email: 'u@example.com' });
await inOther('POST', '/cards', { name: 'Shop', color: '#112233', code: '123', codeType: 'CODE128' });
expect(existsSync(path.join(config.dataDir, 'groups', other.id, 'cards.yaml'))).toBe(true);
expect((await root('DELETE', `/groups/${other.id}`)).status).toBe(204);
expect(existsSync(path.join(config.dataDir, 'groups', other.id))).toBe(false);
expect((await admin(app, 'u@example.com')('GET', '/me')).status).toBe(401);
});
});
describe('users and keys', () => {
it('enforces role rules', async () => {
const { app, root } = await setup();
await root('POST', '/users', { email: 'adm@example.com', role: 'admin' });
await root('POST', '/users', { email: 'User@Example.com' });
const adm = admin(app, 'adm@example.com');
const user = admin(app, 'user@example.com');
expect((await user('GET', '/users')).status).toBe(403);
expect((await user('GET', '/cards')).status).toBe(403);
expect((await adm('POST', '/users', { email: 'x@example.com', role: 'superadmin' })).status).toBe(403);
expect((await adm('PATCH', `/users/${SUPERADMIN}`, { role: 'user' })).status).toBe(403);
expect((await adm('DELETE', `/users/${SUPERADMIN}`)).status).toBe(403);
expect((await adm('PATCH', '/users/adm@example.com', { role: 'user' })).status).toBe(400);
expect((await adm('DELETE', '/users/adm@example.com')).status).toBe(400);
expect((await adm('PATCH', '/users/user@example.com', { role: 'admin' })).status).toBe(200);
expect((await adm('POST', '/users', { email: 'user@example.com' })).status).toBe(409);
expect((await adm('POST', '/users', { email: 'not-an-email' })).status).toBe(400);
expect((await root('PATCH', `/users/${SUPERADMIN}`, { role: 'admin' })).status).toBe(400);
});
it('admin generates keys; users see their own keys with deep links', async () => {
const { app, root } = await setup();
await root('POST', '/users', { email: 'user@example.com' });
const created = await root('POST', '/users/user@example.com/keys', { label: 'Phone' });
expect(created.status).toBe(201);
const key = created.body as AccessKeyView;
expect(key.key).toMatch(/^[A-Za-z0-9]{32}$/);
expect(key.deepLink).toBe(`cardy://login?server=${encodeURIComponent('https://cards.example.com/api')}&key=${key.key}`);
const user = admin(app, 'user@example.com');
expect((await user('POST', '/users/user@example.com/keys', {})).status).toBe(403);
expect((await user('GET', '/my-keys')).body).toEqual([key]);
const listed = (await root('GET', '/users')).body as UserView[];
expect(listed.find((u) => u.email === 'user@example.com')?.keys).toEqual([key]);
expect((await root('DELETE', `/users/user@example.com/keys/${key.id}`)).status).toBe(204);
expect((await user('GET', '/my-keys')).body).toEqual([]);
});
it('stores keys encrypted', async () => {
const { app, config, root } = await setup();
const key = (await root('POST', `/users/${SUPERADMIN}/keys`, {})).body as AccessKeyView;
const { readFile } = await import('node:fs/promises');
const yaml = await readFile(path.join(config.dataDir, 'users.yaml'), 'utf8');
expect(yaml).not.toContain(key.key);
expect(yaml).toContain('encrypted:');
await app.close();
});
});
describe('cards', () => {
it('supports CRUD, soft delete, restore and purge', async () => {
const { root } = await setup();
const created = await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: '4600000000000', codeType: 'EAN13' });
expect(created.status).toBe(201);
const card = created.body as Card;
expect(card).toMatchObject({ name: 'Shop', imagesVersion: 0, deleted: false, logo: null, images: [] });
expect((await root('POST', '/cards', { name: 'x', color: 'red', code: '1', codeType: 'EAN13' })).status).toBe(400);
expect((await root('POST', '/cards', { name: 'x', color: '#000000', code: '1', codeType: 'NOPE' })).status).toBe(400);
const patched = await root('PATCH', `/cards/${card.id}`, { name: 'Shop 2' });
expect((patched.body as Card).name).toBe('Shop 2');
const deleted = await root('DELETE', `/cards/${card.id}`);
expect((deleted.body as Card).deleted).toBe(true);
expect(((await root('GET', '/cards')).body as Card[])[0]?.deleted).toBe(true);
const restored = await root('PATCH', `/cards/${card.id}`, { deleted: false });
expect((restored.body as Card).deleted).toBe(false);
await root('DELETE', `/cards/${card.id}`);
expect((await root('DELETE', `/cards/${card.id}`)).status).toBe(204);
expect((await root('GET', '/cards')).body).toEqual([]);
});
it('manages logo and images with version bumps', async () => {
const { app, root } = await setup();
const card = (await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: 'X', codeType: 'QR' })).body as Card;
const png = Buffer.from('fake-png');
const upload = (url: string, method: 'PUT' | 'POST', parts: Parameters<typeof multipart>[0]) => {
const { payload, headers } = multipart(parts);
return app.inject({ method, url: `/admin${url}`, payload, headers: { ...headers, 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' } });
};
const withLogo = (await upload(`/cards/${card.id}/logo`, 'PUT', [{ name: 'file', filename: 'l.png', mime: 'image/png', data: png }])).json() as Card;
expect(withLogo.logo?.mime).toBe('image/png');
expect(withLogo.imagesVersion).toBe(1);
const logoRes = await root('GET', `/cards/${card.id}/logo`);
expect(logoRes.res.rawPayload).toEqual(png);
expect(logoRes.res.headers['content-type']).toBe('image/png');
expect(logoRes.res.headers['content-security-policy']).toBe('sandbox');
const withImages = (
await upload(`/cards/${card.id}/images`, 'POST', [
{ name: 'file', filename: 'a.webp', mime: 'image/webp', data: Buffer.from('a') },
{ name: 'file', filename: 'b.html', mime: 'text/html', data: Buffer.from('<script>') },
])
).json() as Card;
expect(withImages.images).toHaveLength(2);
expect(withImages.imagesVersion).toBe(2);
const html = await root('GET', `/cards/${card.id}/images/${withImages.images[1]!.id}`);
expect(html.res.headers['content-type']).toBe('application/octet-stream');
const removed = (await root('DELETE', `/cards/${card.id}/images/${withImages.images[0]!.id}`)).body as Card;
expect(removed.images).toHaveLength(1);
expect(removed.imagesVersion).toBe(3);
const noLogo = (await root('DELETE', `/cards/${card.id}/logo`)).body as Card;
expect(noLogo.logo).toBeNull();
expect(noLogo.imagesVersion).toBe(4);
expect((await root('GET', `/cards/${card.id}/logo`)).status).toBe(404);
});
it('isolates cards between groups', async () => {
const { app, root } = await setup();
const card = (await root('POST', '/cards', { name: 'Shop', color: '#AABBCC', code: 'X', codeType: 'QR' })).body as Card;
const other = (await root('POST', '/groups', { name: 'Other' })).body as Group;
const inOther = admin(app, SUPERADMIN, `cardy_group=${other.id}`);
expect((await inOther('GET', '/cards')).body).toEqual([]);
expect((await inOther('PATCH', `/cards/${card.id}`, { name: 'hack' })).status).toBe(404);
});
});
+133
View File
@@ -0,0 +1,133 @@
import type { FastifyInstance } from 'fastify';
import { describe, expect, it } from 'vitest';
import type { AccessKeyView, Card, Group } from '@cardy/shared';
import { SUPERADMIN, multipart, testApp } from './helpers.js';
const adminHeaders = { 'remote-email': SUPERADMIN, 'x-cardy-admin': '1' };
async function setup() {
const { app } = await testApp();
const key = (
await app.inject({ method: 'POST', url: `/admin/users/${SUPERADMIN}/keys`, headers: adminHeaders, payload: {} })
).json() as AccessKeyView;
return { app, key: key.key, keyId: key.id };
}
function client(app: FastifyInstance, key: string) {
return (method: 'GET' | 'POST' | 'PUT' | 'DELETE', url: string, parts?: Parameters<typeof multipart>[0]) => {
const body = parts ? multipart(parts) : undefined;
return app.inject({
method,
url,
headers: { authorization: `Bearer ${key}`, ...body?.headers },
...(body ? { payload: body.payload } : {}),
});
};
}
const cardParts = (overrides: Record<string, string> = {}) =>
Object.entries({ name: 'Shop', color: '#123456', code: '4600000000000', codeType: 'EAN13', ...overrides }).map(
([name, value]) => ({ name, value }),
);
describe('client API', () => {
it('authenticates by bearer key', async () => {
const { app, key } = await setup();
const ok = await client(app, key)('GET', '/auth');
expect(ok.statusCode).toBe(200);
expect(ok.json()).toMatchObject({ email: SUPERADMIN, group: { name: 'default' } });
expect((await client(app, 'wrong')('GET', '/auth')).statusCode).toBe(401);
expect((await app.inject({ url: '/cards' })).statusCode).toBe(401);
});
it('stops accepting a key right after it is deleted', async () => {
const { app, key, keyId } = await setup();
await app.inject({ method: 'DELETE', url: `/admin/users/${SUPERADMIN}/keys/${keyId}`, headers: adminHeaders });
expect((await client(app, key)('GET', '/auth')).statusCode).toBe(401);
});
it('answers CORS preflight without a key', async () => {
const { app } = await setup();
const res = await app.inject({
method: 'OPTIONS',
url: '/cards',
headers: { origin: 'https://localhost', 'access-control-request-method': 'GET', 'access-control-request-headers': 'authorization' },
});
expect(res.statusCode).toBeLessThan(300);
expect(res.headers['access-control-allow-origin']).toBe('https://localhost');
});
it('creates, edits and deletes cards with logo and images', async () => {
const { app, key } = await setup();
const api = client(app, key);
const createdRes = await api('POST', '/cards', [
...cardParts(),
{ name: 'logo', filename: 'logo.webp', mime: 'image/webp', data: Buffer.from('logo') },
]);
expect(createdRes.statusCode).toBe(201);
const created = createdRes.json() as Card;
expect(created.id).toBeTruthy();
expect(created.logo).not.toBeNull();
expect(created.imagesVersion).toBe(1);
expect((await api('POST', '/cards', cardParts({ codeType: 'BAD' }))).statusCode).toBe(400);
const edited = (await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'Shop 2' }])).json() as Card;
expect(edited).toMatchObject({ name: 'Shop 2', code: '4600000000000', imagesVersion: 1 });
const noLogo = (await api('PUT', `/cards/${created.id}`, [{ name: 'removeLogo', value: 'true' }])).json() as Card;
expect(noLogo.logo).toBeNull();
expect(noLogo.imagesVersion).toBe(2);
const withImages = (
await api('POST', `/cards/${created.id}/images`, [
{ name: 'file', filename: 'a.webp', mime: 'image/webp', data: Buffer.from('a') },
{ name: 'file', filename: 'b.webp', mime: 'image/webp', data: Buffer.from('b') },
])
).json() as Card;
expect(withImages.images).toHaveLength(2);
expect(withImages.imagesVersion).toBe(3);
const image = await api('GET', `/cards/${created.id}/images/${withImages.images[1]!.id}`);
expect(image.body).toBe('b');
const afterRemove = (await api('DELETE', `/cards/${created.id}/images/${withImages.images[0]!.id}`)).json() as Card;
expect(afterRemove.images.map((i) => i.id)).toEqual([withImages.images[1]!.id]);
expect((await api('GET', '/cards')).json()).toHaveLength(1);
expect((await api('DELETE', `/cards/${created.id}`)).statusCode).toBe(204);
// Deleted cards are invisible to the client API but kept for admins.
expect((await api('GET', '/cards')).json()).toEqual([]);
expect((await api('GET', `/cards/${created.id}/images/${withImages.images[1]!.id}`)).statusCode).toBe(404);
expect((await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'x' }])).statusCode).toBe(404);
expect((await api('DELETE', `/cards/${created.id}`)).statusCode).toBe(404);
const adminCards = (await app.inject({ url: '/admin/cards', headers: adminHeaders })).json() as Card[];
expect(adminCards[0]).toMatchObject({ id: created.id, deleted: true });
});
it('scopes cards to the key owner group', async () => {
const { app, key } = await setup();
const other = (
await app.inject({ method: 'POST', url: '/admin/groups', headers: adminHeaders, payload: { name: 'Other' } })
).json() as Group;
const otherHeaders = { ...adminHeaders, cookie: `cardy_group=${other.id}` };
await app.inject({ method: 'POST', url: '/admin/users', headers: otherHeaders, payload: { email: 'o@example.com' } });
const otherKey = (
await app.inject({ method: 'POST', url: '/admin/users/o@example.com/keys', headers: otherHeaders, payload: {} })
).json() as AccessKeyView;
const mine = (await client(app, key)('POST', '/cards', cardParts())).json() as Card;
const theirs = client(app, otherKey.key);
expect((await theirs('GET', '/cards')).json()).toEqual([]);
expect((await theirs('DELETE', `/cards/${mine.id}`)).statusCode).toBe(404);
});
it('returns JSON 404 for unknown API routes', async () => {
const { app, key } = await setup();
const res = await client(app, key)('GET', '/nope');
expect(res.statusCode).toBe(404);
expect(res.json()).toEqual({ error: 'Not found' });
});
});
+77
View File
@@ -0,0 +1,77 @@
import { randomBytes } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import type { AccessKeyView } from '@cardy/shared';
import { loadConfig } from '../src/config.js';
import { SUPERADMIN, testApp } from './helpers.js';
const baseEnv = {
KEY_ENCRYPTION_SECRET: randomBytes(32).toString('base64'),
SUPERADMIN_EMAIL: 'Root@Example.com',
};
describe('loadConfig', () => {
it('applies defaults', () => {
const config = loadConfig(baseEnv);
expect(config).toMatchObject({ superadminEmail: 'root@example.com', superadminGroup: 'default', host: '127.0.0.1', port: 10002 });
expect(config.publicApiUrl).toBeUndefined();
expect(config.adminProxySecret).toBeUndefined();
});
it('validates values', () => {
expect(loadConfig({ ...baseEnv, PUBLIC_API_URL: 'https://dev.aggtaa.com/api/' }).publicApiUrl).toBe('https://dev.aggtaa.com/api');
expect(() => loadConfig({ ...baseEnv, PUBLIC_API_URL: 'nope' })).toThrow();
expect(() => loadConfig({ ...baseEnv, SUPERADMIN_EMAIL: '' })).toThrow();
});
});
describe('reverse proxy integration', () => {
const proxied = {
'remote-email': SUPERADMIN,
'x-cardy-admin': '1',
'x-forwarded-proto': 'https',
'x-forwarded-host': 'dev.aggtaa.com',
'x-forwarded-prefix': '/api',
};
it('derives the deep link server and cookie path from X-Forwarded-* headers', async () => {
const { app } = await testApp({ publicApiUrl: undefined });
const key = (await app.inject({ method: 'POST', url: `/admin/users/${SUPERADMIN}/keys`, headers: proxied, payload: {} })).json() as AccessKeyView;
expect(key.deepLink).toBe(`cardy://login?server=${encodeURIComponent('https://dev.aggtaa.com/api')}&key=${key.key}`);
const me = await app.inject({ url: '/admin/me', headers: proxied });
const cookie = String(me.headers['set-cookie']);
expect(cookie).toMatch(/Path=\/api\/admin/);
expect(cookie).toMatch(/Secure/);
});
it('ignores a malformed X-Forwarded-Prefix', async () => {
const { app } = await testApp({ publicApiUrl: undefined });
const key = (
await app.inject({
method: 'POST',
url: `/admin/users/${SUPERADMIN}/keys`,
headers: { ...proxied, 'x-forwarded-prefix': '/api"><script>' },
payload: {},
})
).json() as AccessKeyView;
expect(key.deepLink).toContain(`server=${encodeURIComponent('https://dev.aggtaa.com')}&key=`);
});
it('rejects admin requests without the proxy secret when configured', async () => {
const { app } = await testApp({ adminProxySecret: 's3cret' });
const headers = { 'remote-email': SUPERADMIN };
expect((await app.inject({ url: '/admin/me', headers })).statusCode).toBe(401);
expect((await app.inject({ url: '/admin/me', headers: { ...headers, 'x-cardy-proxy-secret': 'wrong' } })).statusCode).toBe(401);
expect((await app.inject({ url: '/admin/me', headers: { ...headers, 'x-cardy-proxy-secret': 's3cret' } })).statusCode).toBe(200);
});
it('does not answer CORS preflight for admin paths', async () => {
const { app } = await testApp();
const res = await app.inject({
method: 'OPTIONS',
url: '/admin/me',
headers: { origin: 'https://evil.example', 'access-control-request-method': 'GET' },
});
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
});
+51
View File
@@ -0,0 +1,51 @@
import { randomBytes } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach } from 'vitest';
import { buildApp } from '../src/app.js';
import type { Config } from '../src/config.js';
export const SUPERADMIN = 'root@example.com';
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
});
export async function tempDir(): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), 'cardy-test-'));
tempDirs.push(dir);
return dir;
}
export async function testConfig(overrides: Partial<Config> = {}): Promise<Config> {
return {
dataDir: await tempDir(),
keySecret: randomBytes(32),
superadminEmail: SUPERADMIN,
superadminGroup: 'default',
publicApiUrl: 'https://cards.example.com/api',
host: '127.0.0.1',
port: 0,
...overrides,
};
}
export async function testApp(overrides: Partial<Config> = {}) {
const config = await testConfig(overrides);
const app = await buildApp(config);
return { app, config };
}
export function multipart(parts: Array<{ name: string; value: string } | { name: string; filename: string; mime: string; data: Buffer }>) {
const boundary = `----cardy${randomBytes(8).toString('hex')}`;
const chunks: Buffer[] = [];
for (const part of parts) {
let head = `--${boundary}\r\nContent-Disposition: form-data; name="${part.name}"`;
if ('filename' in part) head += `; filename="${part.filename}"\r\nContent-Type: ${part.mime}`;
chunks.push(Buffer.from(`${head}\r\n\r\n`), 'data' in part ? part.data : Buffer.from(part.value), Buffer.from('\r\n'));
}
chunks.push(Buffer.from(`--${boundary}--\r\n`));
return { payload: Buffer.concat(chunks), headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
}
+81
View File
@@ -0,0 +1,81 @@
import { randomBytes } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { parseKeySecret } from '../src/config.js';
import { ACCESS_KEY_LENGTH, decryptSecret, encryptSecret, generateAccessKey } from '../src/crypto.js';
import { YamlFile } from '../src/yamlFile.js';
import { tempDir } from './helpers.js';
describe('crypto', () => {
it('generates 32-char alphanumeric keys', () => {
const keys = new Set(Array.from({ length: 100 }, generateAccessKey));
expect(keys.size).toBe(100);
for (const key of keys) expect(key).toMatch(new RegExp(`^[A-Za-z0-9]{${ACCESS_KEY_LENGTH}}$`));
});
it('round-trips with AES-256-GCM using a random IV', () => {
const secret = randomBytes(32);
const a = encryptSecret('hello', secret);
const b = encryptSecret('hello', secret);
expect(a).not.toBe(b);
expect(decryptSecret(a, secret)).toBe('hello');
});
it('rejects a wrong secret or tampered data', () => {
const secret = randomBytes(32);
const encrypted = encryptSecret('hello', secret);
expect(() => decryptSecret(encrypted, randomBytes(32))).toThrow();
const buf = Buffer.from(encrypted, 'base64');
buf[buf.length - 1]! ^= 1;
expect(() => decryptSecret(buf.toString('base64'), secret)).toThrow();
});
it('validates KEY_ENCRYPTION_SECRET', () => {
expect(parseKeySecret(randomBytes(32).toString('base64'))).toHaveLength(32);
expect(() => parseKeySecret(randomBytes(16).toString('base64'))).toThrow(/32 bytes/);
expect(() => parseKeySecret(undefined)).toThrow(/required/);
});
});
describe('YamlFile', () => {
const make = (file: string) =>
new YamlFile<{ items: number[] }>(file, (raw) => (raw as { items: number[] }) ?? { items: [] }, () => ({ items: [] }));
it('returns empty data for a missing file and persists updates', async () => {
const file = path.join(await tempDir(), 'sub', 'data.yaml');
const store = make(file);
expect(await store.read()).toEqual({ items: [] });
await store.update((d) => d.items.push(1));
expect(await make(file).read()).toEqual({ items: [1] });
});
it('serializes concurrent updates', async () => {
const store = make(path.join(await tempDir(), 'data.yaml'));
await Promise.all(Array.from({ length: 20 }, (_, i) => store.update((d) => d.items.push(i))));
expect((await store.read()).items).toHaveLength(20);
});
it('does not write when the update throws', async () => {
const file = path.join(await tempDir(), 'data.yaml');
const store = make(file);
await store.update((d) => d.items.push(1));
await expect(
store.update((d) => {
d.items.push(2);
throw new Error('boom');
}),
).rejects.toThrow('boom');
expect((await store.read()).items).toEqual([1]);
expect(await readFile(file, 'utf8')).not.toContain('2');
});
it('picks up manual edits', async () => {
const file = path.join(await tempDir(), 'data.yaml');
const store = make(file);
await store.update((d) => d.items.push(1));
await new Promise((r) => setTimeout(r, 20));
await writeFile(file, 'items: [7, 8]\n');
expect((await store.read()).items).toEqual([7, 8]);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src", "test"]
}