diff --git a/.github/workflows/dependencies-ci.yml b/.github/workflows/dependencies-ci.yml index a74eccb..fd1a269 100644 --- a/.github/workflows/dependencies-ci.yml +++ b/.github/workflows/dependencies-ci.yml @@ -1,4 +1,4 @@ -\name: Dependencies CI +name: Dependencies CI on: pull_request: diff --git a/.github/workflows/publish-libs.yml b/.github/workflows/publish-libs.yml deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api/package.json b/apps/api/package.json index f21d0c2..2b37152 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -122,14 +122,9 @@ } }, "dependencies": { - "@fastify/autoload": "~6.0.3", - "@fastify/cookie": "^11.0.2", - "@fastify/middie": "^9.0.3", - "@fastify/sensible": "~6.0.2", - "@fastify/static": "^8.3.0", - "better-sqlite3": "^12.9.0", + "@rod-manager/plugin-pages-server": "0.0.1", + "@rod-manager/server-platform": "0.0.1", "dotenv": "^16.4.7", - "fastify": "5.8.3", - "fastify-plugin": "~5.0.1" + "fastify": "^5.8.3" } } diff --git a/apps/api/src/app/app.ts b/apps/api/src/app/app.ts deleted file mode 100644 index 7d1edec..0000000 --- a/apps/api/src/app/app.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as path from 'path'; -import type { FastifyInstance } from 'fastify'; -import AutoLoad from '@fastify/autoload'; - -export interface AppOptions { - logLevel?: 'string'; -} - -export function app(fastify: FastifyInstance, opts: AppOptions) { - // Place here your custom code! - - // Do not touch the following lines - - // This loads all plugins defined in plugins - // those should be support plugins that are reused - // through your application - fastify.register(AutoLoad, { - dir: path.join(__dirname, 'plugins'), - options: { ...opts }, - }); - - // This loads all plugins defined in routes - // define your routes in one of these - fastify.register(AutoLoad, { - dir: path.join(__dirname, 'routes'), - options: { ...opts }, - }); -} diff --git a/apps/api/src/app/routes/pages.spec.ts b/apps/api/src/app/routes/pages.spec.ts deleted file mode 100644 index 1ace1e7..0000000 --- a/apps/api/src/app/routes/pages.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import Fastify from 'fastify'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import sessionPlugin, { SESSION_COOKIE_NAME } from '../plugins/session'; -import databasePlugin from '../plugins/database'; -import authRoutes from './auth'; -import pagesRoutes from './pages'; - -describe('pages routes', () => { - beforeEach(() => { - process.env.AUTH_DB_PATH = ':memory:'; - process.env.AUTH_SEED_INITIAL_USER = 'true'; - process.env.AUTH_INITIAL_USER_EMAIL = 'admin@rod-manager.local'; - process.env.AUTH_INITIAL_USER_PASSWORD = 'admin1234'; - }); - - afterEach(() => { - delete process.env.AUTH_DB_PATH; - delete process.env.AUTH_SEED_INITIAL_USER; - delete process.env.AUTH_INITIAL_USER_EMAIL; - delete process.env.AUTH_INITIAL_USER_PASSWORD; - }); - - it('lists pages for authenticated users', async () => { - const server = Fastify(); - await server.register(sessionPlugin); - await server.register(databasePlugin); - - authRoutes(server); - pagesRoutes(server); - - const loginResponse = await server.inject({ - method: 'POST', - url: '/api/auth/login', - payload: { - email: 'admin@rod-manager.local', - password: 'admin1234', - }, - }); - - const sessionCookie = loginResponse.cookies.find( - (cookie) => cookie.name === SESSION_COOKIE_NAME, - ); - - const pagesResponse = await server.inject({ - method: 'GET', - url: '/api/pages', - cookies: { - [SESSION_COOKIE_NAME]: sessionCookie?.value ?? '', - }, - }); - - expect(pagesResponse.statusCode).toBe(200); - expect(pagesResponse.json()).toEqual({ - pages: [{ slug: 'about' }, { slug: 'home' }, { slug: 'rules' }], - }); - - await server.close(); - }); - - it('returns page content by slug', async () => { - const server = Fastify(); - await server.register(sessionPlugin); - await server.register(databasePlugin); - - pagesRoutes(server); - - const response = await server.inject({ - method: 'GET', - url: '/api/pages/about', - }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ - page: { - slug: 'about', - contentMd: - '# About\n\nThis page is stored in the database as Markdown content.', - }, - }); - - await server.close(); - }); - - it('returns not found for missing page slug', async () => { - const server = Fastify(); - await server.register(sessionPlugin); - await server.register(databasePlugin); - - pagesRoutes(server); - - const response = await server.inject({ - method: 'GET', - url: '/api/pages/missing-page', - }); - - expect(response.statusCode).toBe(404); - expect(response.json()).toEqual({ message: 'Page not found.' }); - - await server.close(); - }); -}); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 34295df..7c00f62 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -2,7 +2,8 @@ import 'dotenv/config'; import Fastify from 'fastify'; import { existsSync, readFileSync } from 'node:fs'; import type { FastifyInstance } from 'fastify'; -import { app } from './app/app'; +import { createServerPlatform } from '@rod-manager/server-platform'; +import { pagesServerPlugin } from '@rod-manager/plugin-pages-server'; const host = process.env.HOST ?? 'localhost'; const port = process.env.PORT ? Number(process.env.PORT) : 3000; @@ -12,7 +13,6 @@ const defaultDevKeyPath = '.cert/localhost-key.pem'; const defaultDevCertPath = '.cert/localhost-cert.pem'; const httpsOptions = getHttpsOptions(); -// Instantiate Fastify with some config const server = Fastify({ logger: true, https: httpsOptions, @@ -44,10 +44,12 @@ function getHttpsOptions() { }; } -// Register your application as a normal plugin. -server.register(app); +server.register(async (instance) => { + await createServerPlatform(instance, { + plugins: [pagesServerPlugin()], + }); +}); -// Start listening. server.listen({ port, host }, (err) => { if (err) { server.log.error(err); diff --git a/apps/api/tsconfig.app.json b/apps/api/tsconfig.app.json index 6424e1b..6ee375e 100644 --- a/apps/api/tsconfig.app.json +++ b/apps/api/tsconfig.app.json @@ -16,7 +16,10 @@ ], "references": [ { - "path": "../../libs/shared/tsconfig.lib.json" + "path": "../../libs/server-platform/tsconfig.lib.json" + }, + { + "path": "../../libs/plugins/pages/server/tsconfig.lib.json" } ] } diff --git a/apps/api/tsconfig.spec.json b/apps/api/tsconfig.spec.json index 8afa0f3..85da96e 100644 --- a/apps/api/tsconfig.spec.json +++ b/apps/api/tsconfig.spec.json @@ -8,5 +8,10 @@ "tsBuildInfoFile": "dist/tsconfig.spec.tsbuildinfo" }, "include": ["src/**/*.ts"], - "exclude": ["eslint.config.js", "eslint.config.cjs", "eslint.config.mjs"] + "exclude": ["eslint.config.js", "eslint.config.cjs", "eslint.config.mjs"], + "references": [ + { + "path": "../../libs/server-platform/tsconfig.lib.json" + } + ] } diff --git a/apps/api/vitest.config.mts b/apps/api/vitest.config.mts index a15ad69..9f0d347 100644 --- a/apps/api/vitest.config.mts +++ b/apps/api/vitest.config.mts @@ -5,5 +5,6 @@ export default defineConfig({ globals: true, environment: 'node', include: ['src/**/*.{spec,test}.ts'], + passWithNoTests: true, }, }); diff --git a/libs/plugins/pages/server/package.json b/libs/plugins/pages/server/package.json new file mode 100644 index 0000000..cbe269a --- /dev/null +++ b/libs/plugins/pages/server/package.json @@ -0,0 +1,51 @@ +{ + "name": "@rod-manager/plugin-pages-server", + "version": "0.0.1", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "@rod-manager/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "!**/*.tsbuildinfo" + ], + "dependencies": { + "@rod-manager/server-platform": "0.0.1", + "@rod-manager/shared": "0.0.1" + }, + "nx": { + "targets": { + "typecheck": { + "executor": "nx:run-commands", + "options": { + "cwd": "libs/plugins/pages/server", + "command": "node ../../../../node_modules/typescript/bin/tsc --noEmit -p tsconfig.lib.json && node ../../../../node_modules/typescript/bin/tsc --noEmit -p tsconfig.spec.json" + } + }, + "build": { + "executor": "nx:run-commands", + "outputs": [ + "{projectRoot}/dist" + ], + "options": { + "cwd": "libs/plugins/pages/server", + "command": "node ../../../../node_modules/typescript/bin/tsc --build tsconfig.lib.json" + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "cwd": "libs/plugins/pages/server", + "command": "node ../../../../node_modules/vitest/vitest.mjs run --config vitest.config.mts" + } + } + } + } +} diff --git a/libs/plugins/pages/server/src/index.ts b/libs/plugins/pages/server/src/index.ts new file mode 100644 index 0000000..82b3836 --- /dev/null +++ b/libs/plugins/pages/server/src/index.ts @@ -0,0 +1 @@ +export { pagesServerPlugin } from './lib/plugin'; diff --git a/apps/api/src/app/plugins/database/init.spec.ts b/libs/plugins/pages/server/src/lib/migrations.spec.ts similarity index 57% rename from apps/api/src/app/plugins/database/init.spec.ts rename to libs/plugins/pages/server/src/lib/migrations.spec.ts index 1a1d6ce..551d349 100644 --- a/apps/api/src/app/plugins/database/init.spec.ts +++ b/libs/plugins/pages/server/src/lib/migrations.spec.ts @@ -1,15 +1,32 @@ import Database from 'better-sqlite3'; import { afterEach, describe, expect, it } from 'vitest'; -import { ensurePageSlugValidationRules, initializeSchema } from './init'; +import { + pagesSchemaMigration, + pagesValidationRulesMigration, +} from './migrations'; +import type { ServerPlatformPluginContext } from '@rod-manager/server-platform'; const databases: Database.Database[] = []; -function createDatabase(): Database.Database { +function createTestContext(): { + db: Database.Database; + ctx: ServerPlatformPluginContext; +} { const db = new Database(':memory:'); - initializeSchema(db); - ensurePageSlugValidationRules(db); databases.push(db); - return db; + const ctx: ServerPlatformPluginContext = { + fastify: {} as ServerPlatformPluginContext['fastify'], + services: { + authStore: {} as ServerPlatformPluginContext['services']['authStore'], + sessionService: + {} as ServerPlatformPluginContext['services']['sessionService'], + db: db as unknown as ServerPlatformPluginContext['services']['db'], + logger: {} as ServerPlatformPluginContext['services']['logger'], + }, + }; + void pagesSchemaMigration.up(ctx); + void pagesValidationRulesMigration.up(ctx); + return { db, ctx }; } afterEach(() => { @@ -18,9 +35,9 @@ afterEach(() => { } }); -describe('page slug validation rules', () => { +describe('pages migrations', () => { it('rejects slug collision with reserved routes', () => { - const db = createDatabase(); + const { db } = createTestContext(); expect(() => { db.prepare(`INSERT INTO pages (slug, content_md) VALUES (?, ?)`).run( @@ -31,7 +48,7 @@ describe('page slug validation rules', () => { }); it('rejects empty slug values', () => { - const db = createDatabase(); + const { db } = createTestContext(); expect(() => { db.prepare(`INSERT INTO pages (slug, content_md) VALUES (?, ?)`).run( @@ -42,7 +59,7 @@ describe('page slug validation rules', () => { }); it('accepts non-reserved slugs', () => { - const db = createDatabase(); + const { db } = createTestContext(); db.prepare(`INSERT INTO pages (slug, content_md) VALUES (?, ?)`).run( 'community-news', diff --git a/libs/plugins/pages/server/src/lib/migrations.ts b/libs/plugins/pages/server/src/lib/migrations.ts new file mode 100644 index 0000000..695d043 --- /dev/null +++ b/libs/plugins/pages/server/src/lib/migrations.ts @@ -0,0 +1,80 @@ +import type { ServerPlatformMigration } from '@rod-manager/server-platform'; + +const RESERVED_PAGE_SLUGS = ['account', 'register', 'pages', 'auth', 'api']; +const RESERVED_PAGE_SLUGS_SQL = RESERVED_PAGE_SLUGS.map( + (slug) => `'${slug}'`, +).join(', '); +const RESERVED_PAGE_SLUG_ERROR_MESSAGE = + 'Page slug collides with a reserved application route.'; +const EMPTY_PAGE_SLUG_ERROR_MESSAGE = 'Page slug cannot be empty.'; + +export const pagesSchemaMigration: ServerPlatformMigration = { + id: 'pages-schema-v1', + up(ctx) { + ctx.services.db.exec(` + CREATE TABLE IF NOT EXISTS pages ( + slug TEXT PRIMARY KEY CHECK ( + trim(slug) <> '' AND + lower(slug) NOT IN (${RESERVED_PAGE_SLUGS_SQL}) + ), + content_md TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + CREATE INDEX IF NOT EXISTS idx_pages_slug ON pages(slug); + `); + }, +}; + +export const pagesValidationRulesMigration: ServerPlatformMigration = { + id: 'pages-validation-rules-v1', + up(ctx) { + ctx.services.db.exec(` + CREATE TRIGGER IF NOT EXISTS trg_pages_validate_empty_slug_on_insert + BEFORE INSERT ON pages + FOR EACH ROW + WHEN trim(NEW.slug) = '' + BEGIN + SELECT RAISE(ABORT, '${EMPTY_PAGE_SLUG_ERROR_MESSAGE}'); + END; + + CREATE TRIGGER IF NOT EXISTS trg_pages_validate_reserved_slug_on_insert + BEFORE INSERT ON pages + FOR EACH ROW + WHEN lower(NEW.slug) IN (${RESERVED_PAGE_SLUGS_SQL}) + BEGIN + SELECT RAISE(ABORT, '${RESERVED_PAGE_SLUG_ERROR_MESSAGE}'); + END; + + CREATE TRIGGER IF NOT EXISTS trg_pages_validate_empty_slug_on_update + BEFORE UPDATE OF slug ON pages + FOR EACH ROW + WHEN trim(NEW.slug) = '' + BEGIN + SELECT RAISE(ABORT, '${EMPTY_PAGE_SLUG_ERROR_MESSAGE}'); + END; + + CREATE TRIGGER IF NOT EXISTS trg_pages_validate_reserved_slug_on_update + BEFORE UPDATE OF slug ON pages + FOR EACH ROW + WHEN lower(NEW.slug) IN (${RESERVED_PAGE_SLUGS_SQL}) + BEGIN + SELECT RAISE(ABORT, '${RESERVED_PAGE_SLUG_ERROR_MESSAGE}'); + END; + `); + }, +}; + +export const pagesSeedMigration: ServerPlatformMigration = { + id: 'pages-seed-v1', + up(ctx) { + ctx.services.db.exec(` + INSERT OR IGNORE INTO pages (slug, content_md) + VALUES + ('home', '# Home\n\nWelcome to Rod Manager. This home page is stored in the database.'), + ('about', '# About\n\nThis page is stored in the database as Markdown content.'), + ('rules', '# Community Rules\n\n1. Be respectful.\n2. Keep discussions constructive.\n3. Follow project guidelines.'); + `); + }, +}; diff --git a/libs/plugins/pages/server/src/lib/plugin.ts b/libs/plugins/pages/server/src/lib/plugin.ts new file mode 100644 index 0000000..a7a1476 --- /dev/null +++ b/libs/plugins/pages/server/src/lib/plugin.ts @@ -0,0 +1,28 @@ +import type { ServerPlatformPlugin } from '@rod-manager/server-platform'; +import { createPageStore } from './store'; +import { registerPagesRoutes } from './routes'; +import { + pagesSchemaMigration, + pagesValidationRulesMigration, + pagesSeedMigration, +} from './migrations'; + +/** Creates the pages server plugin descriptor for use with createServerPlatform. */ +export function pagesServerPlugin(): ServerPlatformPlugin { + return { + meta: { + id: 'pages', + version: '0.0.1', + description: 'Content pages feature plugin', + }, + migrations: [ + pagesSchemaMigration, + pagesValidationRulesMigration, + pagesSeedMigration, + ], + register(ctx) { + const pageStore = createPageStore(ctx.services.db); + registerPagesRoutes(ctx.fastify, pageStore); + }, + }; +} diff --git a/libs/plugins/pages/server/src/lib/routes.spec.ts b/libs/plugins/pages/server/src/lib/routes.spec.ts new file mode 100644 index 0000000..edc0897 --- /dev/null +++ b/libs/plugins/pages/server/src/lib/routes.spec.ts @@ -0,0 +1,115 @@ +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + createServerPlatform, + SESSION_COOKIE_NAME, +} from '@rod-manager/server-platform'; +import { pagesServerPlugin } from './plugin'; + +describe('pages plugin contract tests', () => { + beforeEach(() => { + process.env.AUTH_DB_PATH = ':memory:'; + process.env.AUTH_SEED_INITIAL_USER = 'true'; + process.env.AUTH_INITIAL_USER_EMAIL = 'admin@rod-manager.local'; + process.env.AUTH_INITIAL_USER_PASSWORD = 'admin1234'; + }); + + afterEach(() => { + delete process.env.AUTH_DB_PATH; + delete process.env.AUTH_SEED_INITIAL_USER; + delete process.env.AUTH_INITIAL_USER_EMAIL; + delete process.env.AUTH_INITIAL_USER_PASSWORD; + }); + + it('GET /api/pages returns { pages: [...] } envelope for authenticated users', async () => { + const server = Fastify(); + await server.register(async (instance) => { + await createServerPlatform(instance, { plugins: [pagesServerPlugin()] }); + }); + + const loginResponse = await server.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { + email: 'admin@rod-manager.local', + password: 'admin1234', + }, + }); + + const sessionCookie = loginResponse.cookies.find( + (cookie) => cookie.name === SESSION_COOKIE_NAME, + ); + + const pagesResponse = await server.inject({ + method: 'GET', + url: '/api/pages', + cookies: { + [SESSION_COOKIE_NAME]: sessionCookie?.value ?? '', + }, + }); + + expect(pagesResponse.statusCode).toBe(200); + const body = pagesResponse.json<{ pages: Array<{ slug: string }> }>(); + expect(body).toHaveProperty('pages'); + expect(Array.isArray(body.pages)).toBe(true); + expect(body.pages.every((p) => typeof p.slug === 'string')).toBe(true); + + await server.close(); + }); + + it('GET /api/pages/:slug returns { page: ... } envelope', async () => { + const server = Fastify(); + await server.register(async (instance) => { + await createServerPlatform(instance, { plugins: [pagesServerPlugin()] }); + }); + + const response = await server.inject({ + method: 'GET', + url: '/api/pages/about', + }); + + expect(response.statusCode).toBe(200); + const body = response.json<{ page: { slug: string; contentMd: string } }>(); + expect(body).toHaveProperty('page'); + expect(body.page).toHaveProperty('slug', 'about'); + expect(body.page).toHaveProperty('contentMd'); + expect(typeof body.page.contentMd).toBe('string'); + + await server.close(); + }); + + it('GET /api/pages/:slug returns 404 with { message } for missing slug', async () => { + const server = Fastify(); + await server.register(async (instance) => { + await createServerPlatform(instance, { plugins: [pagesServerPlugin()] }); + }); + + const response = await server.inject({ + method: 'GET', + url: '/api/pages/nonexistent-slug', + }); + + expect(response.statusCode).toBe(404); + const body = response.json<{ message: string }>(); + expect(body).toHaveProperty('message'); + expect(typeof body.message).toBe('string'); + + await server.close(); + }); + + it('GET /api/pages returns 401 for unauthenticated requests', async () => { + const server = Fastify(); + await server.register(async (instance) => { + await createServerPlatform(instance, { plugins: [pagesServerPlugin()] }); + }); + + const response = await server.inject({ + method: 'GET', + url: '/api/pages', + }); + + expect(response.statusCode).toBe(401); + + await server.close(); + }); +}); diff --git a/apps/api/src/app/routes/pages.ts b/libs/plugins/pages/server/src/lib/routes.ts similarity index 67% rename from apps/api/src/app/routes/pages.ts rename to libs/plugins/pages/server/src/lib/routes.ts index 5bcf4f8..f30c060 100644 --- a/apps/api/src/app/routes/pages.ts +++ b/libs/plugins/pages/server/src/lib/routes.ts @@ -3,8 +3,13 @@ import type { ContentPageListResponseBody, ContentPageResponseBody, } from '@rod-manager/shared'; +import type { PageStore } from './store'; -export default function pagesRoutes(fastify: FastifyInstance) { +/** Registers pages API routes on the given Fastify instance. */ +export function registerPagesRoutes( + fastify: FastifyInstance, + pageStore: PageStore, +): void { fastify.get( '/api/pages', { @@ -14,11 +19,9 @@ export default function pagesRoutes(fastify: FastifyInstance) { if (request.authenticatedSession === undefined) { return; } - const response: ContentPageListResponseBody = { - pages: fastify.pageStore.listPages(), + pages: pageStore.listPages(), }; - await reply.send(response); }, ); @@ -26,17 +29,12 @@ export default function pagesRoutes(fastify: FastifyInstance) { fastify.get<{ Params: { slug: string } }>( '/api/pages/:slug', async (request, reply) => { - const page = fastify.pageStore.findPageBySlug(request.params.slug); - + const page = pageStore.findPageBySlug(request.params.slug); if (page === undefined) { await reply.status(404).send({ message: 'Page not found.' }); return; } - - const response: ContentPageResponseBody = { - page, - }; - + const response: ContentPageResponseBody = { page }; await reply.send(response); }, ); diff --git a/apps/api/src/app/plugins/database/pageStore.ts b/libs/plugins/pages/server/src/lib/store.ts similarity index 53% rename from apps/api/src/app/plugins/database/pageStore.ts rename to libs/plugins/pages/server/src/lib/store.ts index 17983be..17cea33 100644 --- a/apps/api/src/app/plugins/database/pageStore.ts +++ b/libs/plugins/pages/server/src/lib/store.ts @@ -1,13 +1,30 @@ -import type Database from 'better-sqlite3'; -import type { - ContentPage, - ContentPageRow, - ContentPageSummary, - ContentPageSummaryRow, - PageStore, -} from './types'; - -export function createPageStore(db: Database.Database): PageStore { +import type { ServerPlatformDbClient } from '@rod-manager/server-platform'; + +export interface ContentPageSummary { + slug: string; +} + +export interface ContentPage { + slug: string; + contentMd: string; +} + +export interface PageStore { + listPages(): ContentPageSummary[]; + findPageBySlug(slug: string): ContentPage | undefined; +} + +interface ContentPageSummaryRow { + slug: string; +} + +interface ContentPageRow { + slug: string; + content_md: string; +} + +/** Creates a page store backed by the given database client. */ +export function createPageStore(db: ServerPlatformDbClient): PageStore { const listPagesStatement = db.prepare<[], ContentPageSummaryRow>( `SELECT slug FROM pages ORDER BY slug ASC`, ); @@ -24,11 +41,9 @@ export function createPageStore(db: Database.Database): PageStore { }, findPageBySlug(slug: string): ContentPage | undefined { const row = findPageBySlugStatement.get(slug); - if (row === undefined) { return undefined; } - return { slug: row.slug, contentMd: row.content_md, diff --git a/libs/plugins/pages/server/tsconfig.json b/libs/plugins/pages/server/tsconfig.json new file mode 100644 index 0000000..1278b48 --- /dev/null +++ b/libs/plugins/pages/server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.spec.json" } + ] +} diff --git a/libs/plugins/pages/server/tsconfig.lib.json b/libs/plugins/pages/server/tsconfig.lib.json new file mode 100644 index 0000000..3d3d185 --- /dev/null +++ b/libs/plugins/pages/server/tsconfig.lib.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "forceConsistentCasingInFileNames": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], + "references": [ + { + "path": "../../../shared/tsconfig.lib.json" + }, + { + "path": "../../../server-platform/tsconfig.lib.json" + } + ] +} diff --git a/libs/plugins/pages/server/tsconfig.spec.json b/libs/plugins/pages/server/tsconfig.spec.json new file mode 100644 index 0000000..648f05a --- /dev/null +++ b/libs/plugins/pages/server/tsconfig.spec.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "types": ["node", "vitest/globals"], + "rootDir": "src", + "module": "esnext", + "moduleResolution": "bundler", + "tsBuildInfoFile": "dist/tsconfig.spec.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["eslint.config.js", "eslint.config.cjs", "eslint.config.mjs"] +} diff --git a/libs/plugins/pages/server/vitest.config.mts b/libs/plugins/pages/server/vitest.config.mts new file mode 100644 index 0000000..f00ec6c --- /dev/null +++ b/libs/plugins/pages/server/vitest.config.mts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + '@rod-manager/server-platform': resolve( + __dirname, + '../../../../libs/server-platform/src/index.ts', + ), + '@rod-manager/shared': resolve( + __dirname, + '../../../../libs/shared/src/index.ts', + ), + }, + }, + test: { + globals: true, + environment: 'node', + include: ['src/**/*.{spec,test}.ts'], + }, +}); diff --git a/libs/plugins/pages/ui/package.json b/libs/plugins/pages/ui/package.json new file mode 100644 index 0000000..4dc3f61 --- /dev/null +++ b/libs/plugins/pages/ui/package.json @@ -0,0 +1,40 @@ +{ + "name": "@rod-manager/plugin-pages-ui", + "version": "0.0.1", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "@rod-manager/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": ["dist", "!**/*.tsbuildinfo"], + "dependencies": { + "@rod-manager/shared": "0.0.1" + }, + "nx": { + "targets": { + "typecheck": { + "executor": "nx:run-commands", + "options": { + "cwd": "libs/plugins/pages/ui", + "command": "node ../../../../node_modules/typescript/bin/tsc --noEmit -p tsconfig.lib.json" + } + }, + "build": { + "executor": "nx:run-commands", + "outputs": ["{projectRoot}/dist"], + "options": { + "cwd": "libs/plugins/pages/ui", + "command": "node ../../../../node_modules/typescript/bin/tsc --build tsconfig.lib.json" + } + } + } + } +} diff --git a/libs/plugins/pages/ui/src/index.ts b/libs/plugins/pages/ui/src/index.ts new file mode 100644 index 0000000..431e42e --- /dev/null +++ b/libs/plugins/pages/ui/src/index.ts @@ -0,0 +1 @@ +export type { PagesUiPlugin } from './lib/plugin.js'; diff --git a/libs/plugins/pages/ui/src/lib/plugin.ts b/libs/plugins/pages/ui/src/lib/plugin.ts new file mode 100644 index 0000000..5341afa --- /dev/null +++ b/libs/plugins/pages/ui/src/lib/plugin.ts @@ -0,0 +1,11 @@ +/** Placeholder for WebPlatform integration for the pages plugin. */ +export interface PagesUiPlugin { + id: string; +} + +/** Creates the pages UI plugin descriptor for WebPlatform integration. */ +export function pagesUiPlugin(): PagesUiPlugin { + return { + id: 'pages', + }; +} diff --git a/libs/plugins/pages/ui/tsconfig.json b/libs/plugins/pages/ui/tsconfig.json new file mode 100644 index 0000000..1fab1b5 --- /dev/null +++ b/libs/plugins/pages/ui/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.lib.json" } + ] +} diff --git a/libs/plugins/pages/ui/tsconfig.lib.json b/libs/plugins/pages/ui/tsconfig.lib.json new file mode 100644 index 0000000..8f675b7 --- /dev/null +++ b/libs/plugins/pages/ui/tsconfig.lib.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.spec.tsx", + "src/**/*.test.ts", + "src/**/*.test.tsx" + ], + "references": [ + { + "path": "../../../shared/tsconfig.lib.json" + } + ] +} diff --git a/libs/server-platform/package.json b/libs/server-platform/package.json new file mode 100644 index 0000000..7b1a28a --- /dev/null +++ b/libs/server-platform/package.json @@ -0,0 +1,58 @@ +{ + "name": "@rod-manager/server-platform", + "version": "0.0.1", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "@rod-manager/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "!**/*.tsbuildinfo" + ], + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/middie": "^9.0.3", + "@fastify/sensible": "^6.0.2", + "@fastify/static": "^8.3.0", + "@rod-manager/shared": "0.0.1", + "better-sqlite3": "^12.9.0", + "dotenv": "^16.4.7", + "fastify": "^5.8.3", + "fastify-plugin": "^5.0.1" + }, + "nx": { + "targets": { + "typecheck": { + "executor": "nx:run-commands", + "options": { + "cwd": "libs/server-platform", + "command": "node ../../node_modules/typescript/bin/tsc --noEmit -p tsconfig.lib.json && node ../../node_modules/typescript/bin/tsc --noEmit -p tsconfig.spec.json" + } + }, + "build": { + "executor": "nx:run-commands", + "outputs": [ + "{projectRoot}/dist" + ], + "options": { + "cwd": "libs/server-platform", + "command": "node ../../node_modules/typescript/bin/tsc --build tsconfig.lib.json" + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "cwd": "libs/server-platform", + "command": "node ../../node_modules/vitest/vitest.mjs run --config vitest.config.mts" + } + } + } + } +} diff --git a/libs/server-platform/src/index.ts b/libs/server-platform/src/index.ts new file mode 100644 index 0000000..c0d3de3 --- /dev/null +++ b/libs/server-platform/src/index.ts @@ -0,0 +1,19 @@ +export { createServerPlatform } from './lib/createServerPlatform'; +export type { ServerPlatformOptions } from './lib/createServerPlatform'; +export type { + ServerPlatformPlugin, + ServerPlatformPluginMeta, + ServerPlatformPluginContext, + ServerPlatformMigration, + ServerPlatformAuthStore, + ServerPlatformAuthStoreUser, + ServerPlatformAuthSession, + ServerPlatformSessionService, + ServerPlatformDbClient, + ServerPlatformDbStatement, + ApiErrorResponse, + JsonValue, + JsonPrimitive, +} from './lib/contracts/plugin.contract'; +export type { ServerPlatformCapability } from './lib/contracts/capability.contract'; +export { SESSION_COOKIE_NAME } from './lib/plugins/session'; diff --git a/libs/server-platform/src/lib/contracts/capability.contract.ts b/libs/server-platform/src/lib/contracts/capability.contract.ts new file mode 100644 index 0000000..b8bb47e --- /dev/null +++ b/libs/server-platform/src/lib/contracts/capability.contract.ts @@ -0,0 +1,5 @@ +export interface ServerPlatformCapability { + id: string; + version: string; + description?: string; +} diff --git a/libs/server-platform/src/lib/contracts/plugin.contract.ts b/libs/server-platform/src/lib/contracts/plugin.contract.ts new file mode 100644 index 0000000..73a2abf --- /dev/null +++ b/libs/server-platform/src/lib/contracts/plugin.contract.ts @@ -0,0 +1,89 @@ +import type { FastifyBaseLogger, FastifyInstance } from 'fastify'; + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = + | JsonPrimitive + | { [key: string]: JsonValue } + | JsonValue[]; + +export interface ServerPlatformAuthStoreUser { + id: string; + email: string; + role: string; + displayName: string; +} + +export interface ServerPlatformAuthSession { + token: string; + userId: string; + expiresAt: number; + userEmail: string; + userRole: string; +} + +export interface ServerPlatformAuthStore { + findUserById(id: string): ServerPlatformAuthStoreUser | undefined; + findSession(token: string): ServerPlatformAuthSession | undefined; + createSession(userId: string): string; + deleteSession(token: string): void; +} + +export interface ServerPlatformSessionService { + createSession(userId: string): string; + invalidateSession(token: string): void; + deleteExpiredSessions(now: number): void; +} + +export interface ServerPlatformDbStatement< + TParams extends readonly JsonValue[] = readonly JsonValue[], + TResult = JsonValue, +> { + get(...params: TParams): TResult | undefined; + all(...params: TParams): TResult[]; + run(...params: TParams): { changes: number }; +} + +export interface ServerPlatformDbClient { + prepare< + TParams extends readonly JsonValue[] = readonly JsonValue[], + TResult = JsonValue, + >( + sql: string, + ): ServerPlatformDbStatement; + exec(sql: string): void; +} + +export interface ServerPlatformPluginMeta { + id: string; + version: string; + description?: string; + dependsOn?: string[]; + capabilities?: string[]; +} + +export interface ServerPlatformPluginContext { + fastify: FastifyInstance; + services: { + authStore: ServerPlatformAuthStore; + sessionService: ServerPlatformSessionService; + db: ServerPlatformDbClient; + logger: FastifyBaseLogger; + }; +} + +export interface ServerPlatformMigration { + id: string; + up: (ctx: ServerPlatformPluginContext) => Promise | void; +} + +export interface ServerPlatformPlugin { + meta: ServerPlatformPluginMeta; + migrations?: ServerPlatformMigration[]; + register: (ctx: ServerPlatformPluginContext) => Promise | void; +} + +export interface ApiErrorResponse { + message: string; + code?: string; + details?: Record; +} diff --git a/libs/server-platform/src/lib/createServerPlatform.ts b/libs/server-platform/src/lib/createServerPlatform.ts new file mode 100644 index 0000000..6bed09a --- /dev/null +++ b/libs/server-platform/src/lib/createServerPlatform.ts @@ -0,0 +1,41 @@ +import type { FastifyInstance } from 'fastify'; +import type { ServerPlatformPlugin } from './contracts/plugin.contract'; +import { createPluginRegistrar } from './serverPluginRegistry'; +import databasePlugin from './plugins/database'; +import sessionPlugin from './plugins/session'; +import oauthPlugin from './plugins/oauth'; +import sensiblePlugin from './plugins/sensible'; +import authRoutes from './routes/auth'; +import oauthRoutes from './routes/oauth'; +import rootRoute from './routes/root'; +import ssrRoute from './routes/ssr'; +import userSettingsRoutes from './routes/user-settings'; + +export interface ServerPlatformOptions { + logLevel?: string; + plugins?: ServerPlatformPlugin[]; +} + +/** Registers all core plugins and routes on the given Fastify instance. */ +export async function createServerPlatform( + fastify: FastifyInstance, + opts: ServerPlatformOptions = {}, +): Promise { + // Core plugins + await fastify.register(sensiblePlugin); + await fastify.register(databasePlugin); + await fastify.register(sessionPlugin); + await fastify.register(oauthPlugin); + + // Core routes + fastify.register(authRoutes); + fastify.register(oauthRoutes); + fastify.register(rootRoute); + fastify.register(userSettingsRoutes); + fastify.register(ssrRoute); + + // Feature plugins + if (opts.plugins && opts.plugins.length > 0) { + fastify.register(createPluginRegistrar(opts.plugins)); + } +} diff --git a/apps/api/src/app/plugins/database/index.ts b/libs/server-platform/src/lib/plugins/database/index.ts similarity index 84% rename from apps/api/src/app/plugins/database/index.ts rename to libs/server-platform/src/lib/plugins/database/index.ts index 0fe93b6..b2ad6cf 100644 --- a/apps/api/src/app/plugins/database/index.ts +++ b/libs/server-platform/src/lib/plugins/database/index.ts @@ -7,15 +7,13 @@ import { ensureUserSettingsModel, ensureUserRoleColumn, ensureNameColumns, - ensurePageSlugValidationRules, - ensureSeedPages, seedInitialUser, shouldSeedInitialUser, ensureAdministratorExists, } from './init'; import { createStore } from './store'; import { createUserSettingsStore } from './userSettingsStore'; -import { createPageStore } from './pageStore'; +import type { ServerPlatformDbClient } from '../../contracts/plugin.contract'; export type { AuthStore, @@ -24,7 +22,6 @@ export type { OAuthProviderData, OAuthProviderType, UserSettingsStore, - PageStore, } from './types'; export { createSessionExpiration } from './types'; @@ -38,8 +35,6 @@ export default fp(function databasePlugin(fastify: FastifyInstance) { ensureUserSettingsModel(db); ensureUserRoleColumn(db); ensureNameColumns(db); - ensurePageSlugValidationRules(db); - ensureSeedPages(db); if (shouldSeedInitialUser()) { seedInitialUser(db); @@ -49,7 +44,7 @@ export default fp(function databasePlugin(fastify: FastifyInstance) { fastify.decorate('authStore', createStore(db)); fastify.decorate('userSettingsStore', createUserSettingsStore(db)); - fastify.decorate('pageStore', createPageStore(db)); + fastify.decorate('db', db as unknown as ServerPlatformDbClient); fastify.addHook('onClose', async () => { db.close(); diff --git a/apps/api/src/app/plugins/database/init.ts b/libs/server-platform/src/lib/plugins/database/init.ts similarity index 73% rename from apps/api/src/app/plugins/database/init.ts rename to libs/server-platform/src/lib/plugins/database/init.ts index c53ae84..e2ab876 100644 --- a/apps/api/src/app/plugins/database/init.ts +++ b/libs/server-platform/src/lib/plugins/database/init.ts @@ -5,14 +5,6 @@ import type Database from 'better-sqlite3'; import type { CountRow } from './types'; import { hashPassword } from './store'; -const RESERVED_PAGE_SLUGS = ['account', 'register', 'pages', 'auth', 'api']; -const RESERVED_PAGE_SLUGS_SQL = RESERVED_PAGE_SLUGS.map( - (slug) => `'${slug}'`, -).join(', '); -const RESERVED_PAGE_SLUG_ERROR_MESSAGE = - 'Page slug collides with a reserved application route.'; -const EMPTY_PAGE_SLUG_ERROR_MESSAGE = 'Page slug cannot be empty.'; - export function getDatabasePath(): string { const configuredPath = process.env.AUTH_DB_PATH ?? 'tmp/auth.sqlite'; @@ -71,74 +63,8 @@ export function initializeSchema(db: Database.Database): void { FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); - CREATE TABLE IF NOT EXISTS pages ( - slug TEXT PRIMARY KEY CHECK ( - trim(slug) <> '' AND - lower(slug) NOT IN (${RESERVED_PAGE_SLUGS_SQL}) - ), - content_md TEXT NOT NULL, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_oauth_providers_user_id ON oauth_providers(user_id); - CREATE INDEX IF NOT EXISTS idx_pages_slug ON pages(slug); - `); -} - -export function ensurePageSlugValidationRules(db: Database.Database): void { - db.exec(` - CREATE TRIGGER IF NOT EXISTS trg_pages_validate_empty_slug_on_insert - BEFORE INSERT ON pages - FOR EACH ROW - WHEN trim(NEW.slug) = '' - BEGIN - SELECT RAISE(ABORT, '${EMPTY_PAGE_SLUG_ERROR_MESSAGE}'); - END; - - CREATE TRIGGER IF NOT EXISTS trg_pages_validate_reserved_slug_on_insert - BEFORE INSERT ON pages - FOR EACH ROW - WHEN lower(NEW.slug) IN (${RESERVED_PAGE_SLUGS_SQL}) - BEGIN - SELECT RAISE(ABORT, '${RESERVED_PAGE_SLUG_ERROR_MESSAGE}'); - END; - - CREATE TRIGGER IF NOT EXISTS trg_pages_validate_empty_slug_on_update - BEFORE UPDATE OF slug ON pages - FOR EACH ROW - WHEN trim(NEW.slug) = '' - BEGIN - SELECT RAISE(ABORT, '${EMPTY_PAGE_SLUG_ERROR_MESSAGE}'); - END; - - CREATE TRIGGER IF NOT EXISTS trg_pages_validate_reserved_slug_on_update - BEFORE UPDATE OF slug ON pages - FOR EACH ROW - WHEN lower(NEW.slug) IN (${RESERVED_PAGE_SLUGS_SQL}) - BEGIN - SELECT RAISE(ABORT, '${RESERVED_PAGE_SLUG_ERROR_MESSAGE}'); - END; - `); -} - -export function ensureSeedPages(db: Database.Database): void { - db.exec(` - INSERT OR IGNORE INTO pages (slug, content_md) - VALUES - ( - 'home', - '# Home\n\nWelcome to Rod Manager. This home page is stored in the database.' - ), - ( - 'about', - '# About\n\nThis page is stored in the database as Markdown content.' - ), - ( - 'rules', - '# Community Rules\n\n1. Be respectful.\n2. Keep discussions constructive.\n3. Follow project guidelines.' - ); `); } diff --git a/apps/api/src/app/plugins/database/store.ts b/libs/server-platform/src/lib/plugins/database/store.ts similarity index 100% rename from apps/api/src/app/plugins/database/store.ts rename to libs/server-platform/src/lib/plugins/database/store.ts diff --git a/apps/api/src/app/plugins/database/types.ts b/libs/server-platform/src/lib/plugins/database/types.ts similarity index 89% rename from apps/api/src/app/plugins/database/types.ts rename to libs/server-platform/src/lib/plugins/database/types.ts index 22173ab..1493cc4 100644 --- a/apps/api/src/app/plugins/database/types.ts +++ b/libs/server-platform/src/lib/plugins/database/types.ts @@ -3,6 +3,7 @@ import type { UserLanguage, UserRole, } from '@rod-manager/shared'; +import type { ServerPlatformDbClient } from '../../contracts/plugin.contract'; export type { OAuthProviderType }; @@ -91,20 +92,6 @@ export interface UserSettingsStore { updateUserPreferredLanguage(userId: string, language: UserLanguage): void; } -export interface ContentPageSummary { - slug: string; -} - -export interface ContentPage { - slug: string; - contentMd: string; -} - -export interface PageStore { - listPages(): ContentPageSummary[]; - findPageBySlug(slug: string): ContentPage | undefined; -} - export interface UserRow { id: string; email: string; @@ -145,20 +132,11 @@ export interface OAuthProviderRow { created_at: number; } -export interface ContentPageSummaryRow { - slug: string; -} - -export interface ContentPageRow { - slug: string; - content_md: string; -} - declare module 'fastify' { interface FastifyInstance { authStore: AuthStore; userSettingsStore: UserSettingsStore; - pageStore: PageStore; + db: ServerPlatformDbClient; } } diff --git a/apps/api/src/app/plugins/database/userSettingsStore.ts b/libs/server-platform/src/lib/plugins/database/userSettingsStore.ts similarity index 100% rename from apps/api/src/app/plugins/database/userSettingsStore.ts rename to libs/server-platform/src/lib/plugins/database/userSettingsStore.ts diff --git a/apps/api/src/app/plugins/oauth/index.ts b/libs/server-platform/src/lib/plugins/oauth/index.ts similarity index 100% rename from apps/api/src/app/plugins/oauth/index.ts rename to libs/server-platform/src/lib/plugins/oauth/index.ts diff --git a/apps/api/src/app/plugins/oauth/oauthConfigs.ts b/libs/server-platform/src/lib/plugins/oauth/oauthConfigs.ts similarity index 100% rename from apps/api/src/app/plugins/oauth/oauthConfigs.ts rename to libs/server-platform/src/lib/plugins/oauth/oauthConfigs.ts diff --git a/apps/api/src/app/plugins/oauth/pkce.ts b/libs/server-platform/src/lib/plugins/oauth/pkce.ts similarity index 100% rename from apps/api/src/app/plugins/oauth/pkce.ts rename to libs/server-platform/src/lib/plugins/oauth/pkce.ts diff --git a/apps/api/src/app/plugins/oauth/providers.ts b/libs/server-platform/src/lib/plugins/oauth/providers.ts similarity index 100% rename from apps/api/src/app/plugins/oauth/providers.ts rename to libs/server-platform/src/lib/plugins/oauth/providers.ts diff --git a/apps/api/src/app/plugins/oauth/service.ts b/libs/server-platform/src/lib/plugins/oauth/service.ts similarity index 100% rename from apps/api/src/app/plugins/oauth/service.ts rename to libs/server-platform/src/lib/plugins/oauth/service.ts diff --git a/apps/api/src/app/plugins/oauth/types.ts b/libs/server-platform/src/lib/plugins/oauth/types.ts similarity index 100% rename from apps/api/src/app/plugins/oauth/types.ts rename to libs/server-platform/src/lib/plugins/oauth/types.ts diff --git a/apps/api/src/app/plugins/oauth/userInfo.ts b/libs/server-platform/src/lib/plugins/oauth/userInfo.ts similarity index 100% rename from apps/api/src/app/plugins/oauth/userInfo.ts rename to libs/server-platform/src/lib/plugins/oauth/userInfo.ts diff --git a/apps/api/src/app/plugins/sensible.ts b/libs/server-platform/src/lib/plugins/sensible.ts similarity index 100% rename from apps/api/src/app/plugins/sensible.ts rename to libs/server-platform/src/lib/plugins/sensible.ts diff --git a/apps/api/src/app/plugins/session/checkSession.ts b/libs/server-platform/src/lib/plugins/session/checkSession.ts similarity index 100% rename from apps/api/src/app/plugins/session/checkSession.ts rename to libs/server-platform/src/lib/plugins/session/checkSession.ts diff --git a/apps/api/src/app/plugins/session/index.spec.ts b/libs/server-platform/src/lib/plugins/session/index.spec.ts similarity index 100% rename from apps/api/src/app/plugins/session/index.spec.ts rename to libs/server-platform/src/lib/plugins/session/index.spec.ts diff --git a/apps/api/src/app/plugins/session/index.ts b/libs/server-platform/src/lib/plugins/session/index.ts similarity index 100% rename from apps/api/src/app/plugins/session/index.ts rename to libs/server-platform/src/lib/plugins/session/index.ts diff --git a/apps/api/src/app/plugins/session/mutateSession.ts b/libs/server-platform/src/lib/plugins/session/mutateSession.ts similarity index 100% rename from apps/api/src/app/plugins/session/mutateSession.ts rename to libs/server-platform/src/lib/plugins/session/mutateSession.ts diff --git a/apps/api/src/app/plugins/session/requireAuthenticatedSession.ts b/libs/server-platform/src/lib/plugins/session/requireAuthenticatedSession.ts similarity index 100% rename from apps/api/src/app/plugins/session/requireAuthenticatedSession.ts rename to libs/server-platform/src/lib/plugins/session/requireAuthenticatedSession.ts diff --git a/apps/api/src/app/plugins/session/types.ts b/libs/server-platform/src/lib/plugins/session/types.ts similarity index 100% rename from apps/api/src/app/plugins/session/types.ts rename to libs/server-platform/src/lib/plugins/session/types.ts diff --git a/apps/api/src/app/routes/auth.spec.ts b/libs/server-platform/src/lib/routes/auth.spec.ts similarity index 100% rename from apps/api/src/app/routes/auth.spec.ts rename to libs/server-platform/src/lib/routes/auth.spec.ts diff --git a/apps/api/src/app/routes/auth.ts b/libs/server-platform/src/lib/routes/auth.ts similarity index 100% rename from apps/api/src/app/routes/auth.ts rename to libs/server-platform/src/lib/routes/auth.ts diff --git a/apps/api/src/app/routes/oauth.spec.ts b/libs/server-platform/src/lib/routes/oauth.spec.ts similarity index 100% rename from apps/api/src/app/routes/oauth.spec.ts rename to libs/server-platform/src/lib/routes/oauth.spec.ts diff --git a/apps/api/src/app/routes/oauth.ts b/libs/server-platform/src/lib/routes/oauth.ts similarity index 100% rename from apps/api/src/app/routes/oauth.ts rename to libs/server-platform/src/lib/routes/oauth.ts diff --git a/apps/api/src/app/routes/root.spec.ts b/libs/server-platform/src/lib/routes/root.spec.ts similarity index 100% rename from apps/api/src/app/routes/root.spec.ts rename to libs/server-platform/src/lib/routes/root.spec.ts diff --git a/apps/api/src/app/routes/root.ts b/libs/server-platform/src/lib/routes/root.ts similarity index 100% rename from apps/api/src/app/routes/root.ts rename to libs/server-platform/src/lib/routes/root.ts diff --git a/apps/api/src/app/routes/ssr.ts b/libs/server-platform/src/lib/routes/ssr.ts similarity index 100% rename from apps/api/src/app/routes/ssr.ts rename to libs/server-platform/src/lib/routes/ssr.ts diff --git a/apps/api/src/app/routes/user-settings.spec.ts b/libs/server-platform/src/lib/routes/user-settings.spec.ts similarity index 100% rename from apps/api/src/app/routes/user-settings.spec.ts rename to libs/server-platform/src/lib/routes/user-settings.spec.ts diff --git a/apps/api/src/app/routes/user-settings.ts b/libs/server-platform/src/lib/routes/user-settings.ts similarity index 100% rename from apps/api/src/app/routes/user-settings.ts rename to libs/server-platform/src/lib/routes/user-settings.ts diff --git a/libs/server-platform/src/lib/runtime/context.ts b/libs/server-platform/src/lib/runtime/context.ts new file mode 100644 index 0000000..0ff3b2f --- /dev/null +++ b/libs/server-platform/src/lib/runtime/context.ts @@ -0,0 +1 @@ +export type { ServerPlatformPluginContext } from '../contracts/plugin.contract'; diff --git a/libs/server-platform/src/lib/serverPluginRegistry.ts b/libs/server-platform/src/lib/serverPluginRegistry.ts new file mode 100644 index 0000000..ff46931 --- /dev/null +++ b/libs/server-platform/src/lib/serverPluginRegistry.ts @@ -0,0 +1,86 @@ +import type { FastifyInstance } from 'fastify'; +import fp from 'fastify-plugin'; +import type { + ServerPlatformAuthStore, + ServerPlatformPlugin, + ServerPlatformPluginContext, + ServerPlatformSessionService, +} from './contracts/plugin.contract'; +import type { AuthStore } from './plugins/database'; + +/** Creates a Fastify plugin that registers the given ServerPlatformPlugin list in order. */ +export function createPluginRegistrar(plugins: ServerPlatformPlugin[]) { + return fp(async function serverPluginRegistrar(fastify: FastifyInstance) { + for (const plugin of plugins) { + await fastify.register( + fp(async function serverPlugin(instance: FastifyInstance) { + const ctx: ServerPlatformPluginContext = { + fastify: instance, + services: { + authStore: createAuthStoreAdapter(instance.authStore), + sessionService: createSessionServiceAdapter(instance.authStore), + db: instance.db, + logger: instance.log, + }, + }; + + if (plugin.migrations) { + for (const migration of plugin.migrations) { + await migration.up(ctx); + } + } + + await plugin.register(ctx); + }), + ); + } + }); +} + +function createSessionServiceAdapter( + authStore: AuthStore, +): ServerPlatformSessionService { + return { + createSession(userId: string): string { + return authStore.createSession(userId); + }, + invalidateSession(token: string): void { + authStore.deleteSession(token); + }, + deleteExpiredSessions(now: number): void { + authStore.deleteExpiredSessions(now); + }, + }; +} + +function createAuthStoreAdapter(authStore: AuthStore): ServerPlatformAuthStore { + return { + findUserById(id: string) { + const user = authStore.findUserById(id); + if (user === undefined) return undefined; + return { + id: user.id, + email: user.email, + role: user.role, + displayName: user.displayName, + }; + }, + findSession(token: string) { + const session = authStore.findSession(token); + if (session === undefined) return undefined; + return { + token: session.token, + userId: session.userId, + expiresAt: session.expiresAt, + userEmail: session.userEmail, + userRole: session.userRole, + }; + }, + createSession(userId: string): string { + return authStore.createSession(userId); + }, + deleteSession(token: string): void { + authStore.deleteSession(token); + }, + }; +} diff --git a/libs/server-platform/tsconfig.json b/libs/server-platform/tsconfig.json new file mode 100644 index 0000000..c823650 --- /dev/null +++ b/libs/server-platform/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.spec.json" } + ] +} diff --git a/libs/server-platform/tsconfig.lib.json b/libs/server-platform/tsconfig.lib.json new file mode 100644 index 0000000..7a473ce --- /dev/null +++ b/libs/server-platform/tsconfig.lib.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "forceConsistentCasingInFileNames": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], + "references": [ + { + "path": "../shared/tsconfig.lib.json" + } + ] +} diff --git a/libs/server-platform/tsconfig.spec.json b/libs/server-platform/tsconfig.spec.json new file mode 100644 index 0000000..8afa0f3 --- /dev/null +++ b/libs/server-platform/tsconfig.spec.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node", "vitest/globals"], + "rootDir": "src", + "module": "esnext", + "moduleResolution": "bundler", + "tsBuildInfoFile": "dist/tsconfig.spec.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["eslint.config.js", "eslint.config.cjs", "eslint.config.mjs"] +} diff --git a/libs/server-platform/vitest.config.mts b/libs/server-platform/vitest.config.mts new file mode 100644 index 0000000..a15ad69 --- /dev/null +++ b/libs/server-platform/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.{spec,test}.ts'], + }, +}); diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index b897b5a..3380e28 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -8,5 +8,5 @@ export { } from './lib/Typography'; export * from './lib/Link'; export { Page } from './lib/Page'; -export { ModalWindow, type ModalWindowProps } from './lib/ModalWindow/index'; -export type { ModalWindowApi } from './lib/ModalWindow/index'; +export { ModalWindow, type ModalWindowProps } from './lib/ModalWindow'; +export type { ModalWindowApi } from './lib/ModalWindow'; diff --git a/package-lock.json b/package-lock.json index d5084ce..fef821c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "license": "MIT", "workspaces": [ "apps/*", - "libs/*" + "libs/*", + "libs/plugins/pages/*" ], "dependencies": { "@fastify/autoload": "6.0.3", @@ -98,15 +99,10 @@ "name": "@rod-manager/api", "version": "0.0.1", "dependencies": { - "@fastify/autoload": "~6.0.3", - "@fastify/cookie": "^11.0.2", - "@fastify/middie": "^9.0.3", - "@fastify/sensible": "~6.0.2", - "@fastify/static": "^8.3.0", - "better-sqlite3": "^12.9.0", + "@rod-manager/plugin-pages-server": "0.0.1", + "@rod-manager/server-platform": "0.0.1", "dotenv": "^16.4.7", - "fastify": "5.8.3", - "fastify-plugin": "~5.0.1" + "fastify": "^5.8.3" } }, "apps/web": { @@ -118,6 +114,36 @@ "zod": "^4.3.6" } }, + "libs/plugins/pages/server": { + "name": "@rod-manager/plugin-pages-server", + "version": "0.0.1", + "dependencies": { + "@rod-manager/server-platform": "0.0.1", + "@rod-manager/shared": "0.0.1" + } + }, + "libs/plugins/pages/ui": { + "name": "@rod-manager/plugin-pages-ui", + "version": "0.0.1", + "dependencies": { + "@rod-manager/shared": "0.0.1" + } + }, + "libs/server-platform": { + "name": "@rod-manager/server-platform", + "version": "0.0.1", + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@fastify/middie": "^9.0.3", + "@fastify/sensible": "^6.0.2", + "@fastify/static": "^8.3.0", + "@rod-manager/shared": "0.0.1", + "better-sqlite3": "^12.9.0", + "dotenv": "^16.4.7", + "fastify": "^5.8.3", + "fastify-plugin": "^5.0.1" + } + }, "libs/shared": { "name": "@rod-manager/shared", "version": "0.0.1" @@ -5853,6 +5879,18 @@ "resolved": "apps/api", "link": true }, + "node_modules/@rod-manager/plugin-pages-server": { + "resolved": "libs/plugins/pages/server", + "link": true + }, + "node_modules/@rod-manager/plugin-pages-ui": { + "resolved": "libs/plugins/pages/ui", + "link": true + }, + "node_modules/@rod-manager/server-platform": { + "resolved": "libs/server-platform", + "link": true + }, "node_modules/@rod-manager/shared": { "resolved": "libs/shared", "link": true diff --git a/package.json b/package.json index d8b6454..8717098 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "private": true, "workspaces": [ "apps/*", - "libs/*" + "libs/*", + "libs/plugins/pages/*" ], "devDependencies": { "@babel/core": "7.29.0", diff --git a/tsconfig.base.json b/tsconfig.base.json index 3ed515a..ff25ea6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -3,6 +3,7 @@ "composite": true, "declarationMap": true, "emitDeclarationOnly": true, + "outDir": "./dist", "importHelpers": true, "isolatedModules": true, "lib": ["es2022"], diff --git a/tsconfig.json b/tsconfig.json index 4127f04..67d253b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,15 @@ }, { "path": "./libs/ui" + }, + { + "path": "./libs/plugins/pages/server" + }, + { + "path": "./libs/plugins/pages/ui" + }, + { + "path": "./libs/server-platform" } ] }