diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 077b60c6..db917482 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@bytelyst/cosmos': specifier: workspace:* version: link:../../packages/cosmos + '@bytelyst/dashboard-components': + specifier: workspace:* + version: link:../../packages/dashboard-components '@bytelyst/datastore': specifier: workspace:* version: link:../../packages/datastore @@ -188,7 +191,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) husky: specifier: ^9.0.0 version: 9.1.7 @@ -228,6 +231,9 @@ importers: '@bytelyst/config': specifier: workspace:* version: link:../../packages/config + '@bytelyst/dashboard-components': + specifier: workspace:* + version: link:../../packages/dashboard-components '@bytelyst/errors': specifier: workspace:* version: link:../../packages/errors @@ -282,7 +288,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) husky: specifier: ^9.0.0 version: 9.1.7 @@ -375,23 +381,71 @@ importers: specifier: '>=4.0.0' version: 4.9.1(@azure/core-client@1.10.1) + packages/create-app: + devDependencies: + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/dashboard-components: devDependencies: + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/react': - specifier: ^19.0.0 + specifier: ^19.2.14 version: 19.2.14 '@types/react-dom': - specifier: ^19.0.0 + specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + happy-dom: + specifier: ^18.0.1 + version: 18.0.1 react: - specifier: ^19.0.0 + specifier: ^19.2.4 version: 19.2.4 react-dom: - specifier: ^19.0.0 + specifier: ^19.2.4 version: 19.2.4(react@19.2.4) typescript: specifier: ^5.7.3 version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + + packages/dashboard-shell: + devDependencies: + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + happy-dom: + specifier: ^18.0.1 + version: 18.0.1 + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/datastore: dependencies: @@ -839,6 +893,9 @@ importers: stripe: specifier: ^17.5.0 version: 17.7.0 + yaml: + specifier: ^2.8.2 + version: 2.8.2 zod: specifier: ^3.24.2 version: 3.25.76 @@ -17733,6 +17790,15 @@ snapshots: msw: 2.12.10(@types/node@20.19.33)(typescript@5.9.3) vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.0.18(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3) + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -23590,6 +23656,46 @@ snapshots: - tsx - yaml + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.19.11 + happy-dom: 18.0.1 + jsdom: 28.0.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vlq@1.0.1: {} w3c-xmlserializer@5.0.0: diff --git a/services/platform-service/package.json b/services/platform-service/package.json index e4898f37..f336f95e 100644 --- a/services/platform-service/package.json +++ b/services/platform-service/package.json @@ -18,7 +18,6 @@ "@azure/cosmos": "^4.2.0", "@bytelyst/auth": "workspace:*", "@bytelyst/blob": "workspace:*", - "@bytelyst/storage": "workspace:*", "@bytelyst/config": "workspace:*", "@bytelyst/cosmos": "workspace:*", "@bytelyst/datastore": "workspace:*", @@ -26,6 +25,7 @@ "@bytelyst/events": "workspace:*", "@bytelyst/fastify-core": "workspace:*", "@bytelyst/queue": "workspace:*", + "@bytelyst/storage": "workspace:*", "@fastify/cors": "^10.0.2", "@fastify/rate-limit": "^10.3.0", "@fastify/swagger": "^9.4.2", @@ -37,6 +37,7 @@ "jose": "^6.0.8", "nodemailer": "^6.10.1", "stripe": "^17.5.0", + "yaml": "^2.8.2", "zod": "^3.24.2", "zod-openapi": "^5.4.6" }, diff --git a/services/platform-service/src/lib/declarative-loader.test.ts b/services/platform-service/src/lib/declarative-loader.test.ts new file mode 100644 index 00000000..45aef528 --- /dev/null +++ b/services/platform-service/src/lib/declarative-loader.test.ts @@ -0,0 +1,492 @@ +/** + * Declarative YAML module loader — unit tests. + */ + +import { describe, it, expect } from 'vitest'; +import Fastify from 'fastify'; +import { + buildCreateSchema, + buildUpdateSchema, + defaultEndpoints, + fieldToZodSchema, +} from './declarative-schema.js'; +import { MemoryStore, parseModuleYaml, registerModuleRoutes } from './declarative-loader.js'; + +// ── Sample YAML ────────────────────────────────────────────────────────── + +const SAMPLE_YAML = ` +name: announcements +container: announcements +partitionKey: /productId +idPrefix: ann +description: Product announcements +fields: + title: + type: string + required: true + minLength: 1 + maxLength: 200 + body: + type: string + maxLength: 5000 + default: "" + priority: + type: enum + values: [low, medium, high] + default: medium + published: + type: boolean + default: false + viewCount: + type: number + min: 0 + default: 0 +`; + +// ── Schema Validation ──────────────────────────────────────────────────── + +describe('ModuleSchema', () => { + it('parses valid module definition', () => { + const mod = parseModuleYaml(SAMPLE_YAML); + expect(mod.name).toBe('announcements'); + expect(mod.container).toBe('announcements'); + expect(mod.partitionKey).toBe('/productId'); + expect(mod.idPrefix).toBe('ann'); + expect(Object.keys(mod.fields)).toHaveLength(5); + }); + + it('rejects module without name', () => { + expect(() => parseModuleYaml('container: test\nfields:\n x:\n type: string')).toThrow(); + }); + + it('rejects module without fields', () => { + expect(() => parseModuleYaml('name: test\ncontainer: test')).toThrow(); + }); + + it('defaults partitionKey to /productId', () => { + const mod = parseModuleYaml( + 'name: test\ncontainer: test_items\nfields:\n title:\n type: string' + ); + expect(mod.partitionKey).toBe('/productId'); + }); + + it('defaults idPrefix to empty string', () => { + const mod = parseModuleYaml( + 'name: test\ncontainer: test_items\nfields:\n title:\n type: string' + ); + expect(mod.idPrefix).toBe(''); + }); +}); + +// ── Field to Zod ───────────────────────────────────────────────────────── + +describe('fieldToZodSchema', () => { + it('creates string schema with constraints', () => { + const schema = fieldToZodSchema('name', { + type: 'string', + required: true, + minLength: 1, + maxLength: 100, + }); + expect(schema.parse('hello')).toBe('hello'); + expect(() => schema.parse('')).toThrow(); + }); + + it('creates number schema with min/max', () => { + const schema = fieldToZodSchema('count', { type: 'number', required: true, min: 0, max: 100 }); + expect(schema.parse(50)).toBe(50); + expect(() => schema.parse(-1)).toThrow(); + expect(() => schema.parse(101)).toThrow(); + }); + + it('creates boolean schema', () => { + const schema = fieldToZodSchema('active', { type: 'boolean', required: true }); + expect(schema.parse(true)).toBe(true); + expect(() => schema.parse('yes')).toThrow(); + }); + + it('creates enum schema', () => { + const schema = fieldToZodSchema('status', { + type: 'enum', + required: true, + values: ['draft', 'published'], + }); + expect(schema.parse('draft')).toBe('draft'); + expect(() => schema.parse('unknown')).toThrow(); + }); + + it('throws for enum without values', () => { + expect(() => fieldToZodSchema('status', { type: 'enum', required: true })).toThrow(); + }); + + it('applies default value', () => { + const schema = fieldToZodSchema('count', { type: 'number', required: false, default: 0 }); + expect(schema.parse(undefined)).toBe(0); + }); + + it('makes non-required fields optional', () => { + const schema = fieldToZodSchema('note', { type: 'string', required: false }); + expect(schema.parse(undefined)).toBeUndefined(); + }); +}); + +// ── Build Schemas ──────────────────────────────────────────────────────── + +describe('buildCreateSchema / buildUpdateSchema', () => { + const mod = parseModuleYaml(SAMPLE_YAML); + + it('buildCreateSchema validates required fields', () => { + const schema = buildCreateSchema(mod); + const result = schema.parse({ title: 'Hello' }); + expect(result.title).toBe('Hello'); + expect(result.body).toBe(''); + expect(result.priority).toBe('medium'); + expect(result.published).toBe(false); + }); + + it('buildCreateSchema rejects missing required field', () => { + const schema = buildCreateSchema(mod); + expect(() => schema.parse({ body: 'no title' })).toThrow(); + }); + + it('buildUpdateSchema makes all fields optional', () => { + const schema = buildUpdateSchema(mod); + const result = schema.parse({ title: 'Updated' }); + expect(result.title).toBe('Updated'); + expect(result.body).toBeUndefined(); + }); + + it('buildUpdateSchema accepts empty object', () => { + const schema = buildUpdateSchema(mod); + const result = schema.parse({}); + expect(Object.keys(result).length).toBe(0); + }); +}); + +// ── Default Endpoints ──────────────────────────────────────────────────── + +describe('defaultEndpoints', () => { + it('generates 5 CRUD endpoints', () => { + const mod = parseModuleYaml(SAMPLE_YAML); + const eps = defaultEndpoints(mod); + expect(eps).toHaveLength(5); + expect(eps.map(e => e.method)).toEqual(['GET', 'GET', 'POST', 'PATCH', 'DELETE']); + }); + + it('uses module name as base path', () => { + const mod = parseModuleYaml(SAMPLE_YAML); + const eps = defaultEndpoints(mod); + expect(eps[0].path).toBe('/announcements'); + expect(eps[1].path).toBe('/announcements/:id'); + }); + + it('sets DELETE to admin auth', () => { + const mod = parseModuleYaml(SAMPLE_YAML); + const eps = defaultEndpoints(mod); + const deleteEp = eps.find(e => e.method === 'DELETE'); + expect(deleteEp?.auth).toBe('admin'); + }); +}); + +// ── MemoryStore ────────────────────────────────────────────────────────── + +describe('MemoryStore', () => { + it('creates and retrieves documents', async () => { + const store = new MemoryStore(); + const doc = { id: '1', productId: 'test', title: 'Hello' }; + await store.create('items', doc); + const found = await store.get('items', '1', 'test'); + expect(found?.title).toBe('Hello'); + }); + + it('returns null for non-existent document', async () => { + const store = new MemoryStore(); + const found = await store.get('items', 'nope', 'test'); + expect(found).toBeNull(); + }); + + it('returns null for wrong productId', async () => { + const store = new MemoryStore(); + await store.create('items', { id: '1', productId: 'a' }); + const found = await store.get('items', '1', 'b'); + expect(found).toBeNull(); + }); + + it('lists documents with pagination', async () => { + const store = new MemoryStore(); + for (let i = 0; i < 10; i++) { + await store.create('items', { + id: String(i), + productId: 'test', + createdAt: new Date(2026, 0, i + 1).toISOString(), + }); + } + const { items, total } = await store.list('items', 'test', { limit: 3, offset: 0 }); + expect(items).toHaveLength(3); + expect(total).toBe(10); + }); + + it('lists with filters', async () => { + const store = new MemoryStore(); + await store.create('items', { id: '1', productId: 'test', status: 'active' }); + await store.create('items', { id: '2', productId: 'test', status: 'archived' }); + const { items } = await store.list('items', 'test', { filters: { status: 'active' } }); + expect(items).toHaveLength(1); + expect(items[0].id).toBe('1'); + }); + + it('updates a document', async () => { + const store = new MemoryStore(); + await store.create('items', { id: '1', productId: 'test', title: 'Old' }); + const updated = await store.update('items', '1', 'test', { title: 'New' }); + expect(updated?.title).toBe('New'); + expect(updated?.updatedAt).toBeDefined(); + }); + + it('returns null when updating non-existent', async () => { + const store = new MemoryStore(); + const result = await store.update('items', 'nope', 'test', { title: 'x' }); + expect(result).toBeNull(); + }); + + it('deletes a document', async () => { + const store = new MemoryStore(); + await store.create('items', { id: '1', productId: 'test' }); + const ok = await store.delete('items', '1', 'test'); + expect(ok).toBe(true); + const found = await store.get('items', '1', 'test'); + expect(found).toBeNull(); + }); + + it('returns false when deleting non-existent', async () => { + const store = new MemoryStore(); + const ok = await store.delete('items', 'nope', 'test'); + expect(ok).toBe(false); + }); +}); + +// ── Route Integration ──────────────────────────────────────────────────── + +describe('registerModuleRoutes', () => { + async function buildTestApp() { + const app = Fastify({ logger: false }); + // Minimal JWT extraction decorator + app.decorateRequest('jwtPayload', null); + app.addHook('preHandler', async req => { + const auth = req.headers.authorization; + if (auth?.startsWith('Bearer ')) { + try { + const payload = JSON.parse(Buffer.from(auth.split('.')[1], 'base64').toString()); + (req as unknown as { jwtPayload: unknown }).jwtPayload = payload; + } catch { + /* no-op */ + } + } + }); + // Error handler that respects statusCode on thrown errors + app.setErrorHandler((err: Error & { statusCode?: number }, _req, reply) => { + const code = err.statusCode ?? 500; + reply.status(code).send({ error: err.message }); + }); + return app; + } + + function makeJwt(sub: string, role: string = 'user'): string { + const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64'); + const payload = Buffer.from(JSON.stringify({ sub, role })).toString('base64'); + return `Bearer ${header}.${payload}.sig`; + } + + const HEADERS = { + 'x-product-id': 'testapp', + 'content-type': 'application/json', + authorization: makeJwt('user-1'), + }; + + const ADMIN_HEADERS = { + ...HEADERS, + authorization: makeJwt('admin-1', 'admin'), + }; + + it('registers CRUD routes from module definition', async () => { + const app = await buildTestApp(); + const store = new MemoryStore(); + const mod = parseModuleYaml(SAMPLE_YAML); + registerModuleRoutes(app, mod, store); + await app.ready(); + + // CREATE + const createRes = await app.inject({ + method: 'POST', + url: '/announcements', + headers: HEADERS, + payload: { title: 'Launch Day' }, + }); + expect(createRes.statusCode).toBe(201); + const created = createRes.json(); + expect(created.title).toBe('Launch Day'); + expect(created.id).toMatch(/^ann_/); + expect(created.productId).toBe('testapp'); + expect(created.priority).toBe('medium'); + + // LIST + const listRes = await app.inject({ + method: 'GET', + url: '/announcements', + headers: HEADERS, + }); + expect(listRes.statusCode).toBe(200); + const listed = listRes.json(); + expect(listed.items).toHaveLength(1); + expect(listed.total).toBe(1); + + // GET BY ID + const getRes = await app.inject({ + method: 'GET', + url: `/announcements/${created.id}`, + headers: HEADERS, + }); + expect(getRes.statusCode).toBe(200); + expect(getRes.json().title).toBe('Launch Day'); + + // UPDATE + const updateRes = await app.inject({ + method: 'PATCH', + url: `/announcements/${created.id}`, + headers: HEADERS, + payload: { title: 'Updated Title', published: true }, + }); + expect(updateRes.statusCode).toBe(200); + expect(updateRes.json().title).toBe('Updated Title'); + expect(updateRes.json().published).toBe(true); + + // DELETE (requires admin) — omit content-type for DELETE + const { 'content-type': _ignoreCtUser, ...deleteUserHeaders } = HEADERS; // eslint-disable-line @typescript-eslint/no-unused-vars + const deleteUserRes = await app.inject({ + method: 'DELETE', + url: `/announcements/${created.id}`, + headers: deleteUserHeaders, + }); + expect(deleteUserRes.statusCode).toBe(403); + + const { 'content-type': _ignoreCtAdmin, ...deleteAdminHeaders } = ADMIN_HEADERS; // eslint-disable-line @typescript-eslint/no-unused-vars + const deleteAdminRes = await app.inject({ + method: 'DELETE', + url: `/announcements/${created.id}`, + headers: deleteAdminHeaders, + }); + expect(deleteAdminRes.statusCode).toBe(204); + + // Verify deleted + const getDeletedRes = await app.inject({ + method: 'GET', + url: `/announcements/${created.id}`, + headers: HEADERS, + }); + expect(getDeletedRes.statusCode).toBe(404); + + await app.close(); + }); + + it('rejects requests without auth', async () => { + const app = await buildTestApp(); + const store = new MemoryStore(); + const mod = parseModuleYaml(SAMPLE_YAML); + registerModuleRoutes(app, mod, store); + await app.ready(); + + const res = await app.inject({ + method: 'GET', + url: '/announcements', + headers: { 'x-product-id': 'testapp' }, // no auth + }); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it('rejects requests without x-product-id', async () => { + const app = await buildTestApp(); + const store = new MemoryStore(); + const mod = parseModuleYaml(SAMPLE_YAML); + registerModuleRoutes(app, mod, store); + await app.ready(); + + const res = await app.inject({ + method: 'GET', + url: '/announcements', + headers: { authorization: makeJwt('user-1') }, // no product id + }); + expect(res.statusCode).toBe(400); + await app.close(); + }); + + it('validates create input against schema', async () => { + const app = await buildTestApp(); + const store = new MemoryStore(); + const mod = parseModuleYaml(SAMPLE_YAML); + registerModuleRoutes(app, mod, store); + await app.ready(); + + // Missing required 'title' + const res = await app.inject({ + method: 'POST', + url: '/announcements', + headers: HEADERS, + payload: { body: 'no title field' }, + }); + // Zod will throw, Fastify returns 500 by default (or 400 with proper error handler) + expect(res.statusCode).toBeGreaterThanOrEqual(400); + await app.close(); + }); + + it('supports custom endpoints in YAML', () => { + const yaml = ` +name: widgets +container: widgets +fields: + label: + type: string + required: true +endpoints: + - method: GET + path: /widgets + auth: none + - method: POST + path: /widgets/import + auth: admin + custom: true +`; + const mod = parseModuleYaml(yaml); + expect(mod.endpoints).toHaveLength(2); + expect(mod.endpoints![1].custom).toBe(true); + }); + + it('supports auth: none endpoints', async () => { + const yaml = ` +name: public-items +container: public_items +fields: + title: + type: string + required: true +endpoints: + - method: GET + path: /public-items + auth: none +`; + const app = await buildTestApp(); + const store = new MemoryStore(); + const mod = parseModuleYaml(yaml); + registerModuleRoutes(app, mod, store); + await app.ready(); + + // No auth needed + const res = await app.inject({ + method: 'GET', + url: '/public-items', + headers: { 'x-product-id': 'testapp' }, + }); + expect(res.statusCode).toBe(200); + await app.close(); + }); +}); diff --git a/services/platform-service/src/lib/declarative-loader.ts b/services/platform-service/src/lib/declarative-loader.ts new file mode 100644 index 00000000..b2a2976b --- /dev/null +++ b/services/platform-service/src/lib/declarative-loader.ts @@ -0,0 +1,312 @@ +/** + * Declarative YAML module loader. + * + * Reads .module.yaml files and generates Fastify CRUD routes at runtime. + * No code generation — routes are created on the fly at startup. + * + * Usage: + * await loadDeclarativeModules(app, path.join(__dirname, '../modules/declarative')); + */ + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { parse as parseYaml } from 'yaml'; +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import crypto from 'node:crypto'; +import { + ModuleSchema, + buildCreateSchema, + buildUpdateSchema, + defaultEndpoints, + type ModuleDef, + type EndpointDef, +} from './declarative-schema.js'; + +// ── Auth helpers ────────────────────────────────────────────────────────── + +interface JwtPayload { + sub: string; + role?: string; +} + +function getUserId(req: FastifyRequest): string { + const jwt = (req as unknown as { jwtPayload?: JwtPayload }).jwtPayload; + if (!jwt?.sub) throw Object.assign(new Error('Authentication required'), { statusCode: 401 }); + return jwt.sub; +} + +function requireAdmin(req: FastifyRequest): string { + const userId = getUserId(req); + const jwt = (req as unknown as { jwtPayload?: JwtPayload }).jwtPayload; + if (jwt?.role !== 'admin') + throw Object.assign(new Error('Admin access required'), { statusCode: 403 }); + return userId; +} + +function getProductId(req: FastifyRequest): string { + const pid = req.headers['x-product-id'] as string | undefined; + if (!pid) throw Object.assign(new Error('x-product-id header required'), { statusCode: 400 }); + return pid; +} + +// ── In-memory store (for testing / no-DB mode) ─────────────────────────── + +export class MemoryStore { + private collections = new Map>>(); + + getCollection(name: string): Map> { + if (!this.collections.has(name)) { + this.collections.set(name, new Map()); + } + return this.collections.get(name)!; + } + + async create(container: string, doc: Record): Promise> { + this.getCollection(container).set(doc.id as string, { ...doc }); + return doc; + } + + async get( + container: string, + id: string, + productId: string + ): Promise | null> { + const doc = this.getCollection(container).get(id); + if (!doc || doc.productId !== productId) return null; + return doc; + } + + async list( + container: string, + productId: string, + opts: { + sortBy?: string; + sortDir?: 'asc' | 'desc'; + limit?: number; + offset?: number; + filters?: Record; + } = {} + ): Promise<{ items: Record[]; total: number }> { + let items = Array.from(this.getCollection(container).values()).filter( + d => d.productId === productId + ); + + // Apply filters + if (opts.filters) { + for (const [key, value] of Object.entries(opts.filters)) { + items = items.filter(d => d[key] === value); + } + } + + const total = items.length; + + // Sort + if (opts.sortBy) { + const dir = opts.sortDir === 'desc' ? -1 : 1; + items.sort((a, b) => { + const va = String(a[opts.sortBy!] ?? ''); + const vb = String(b[opts.sortBy!] ?? ''); + return va.localeCompare(vb) * dir; + }); + } + + // Paginate + const offset = opts.offset ?? 0; + const limit = opts.limit ?? 50; + items = items.slice(offset, offset + limit); + + return { items, total }; + } + + async update( + container: string, + id: string, + productId: string, + updates: Record + ): Promise | null> { + const existing = await this.get(container, id, productId); + if (!existing) return null; + const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + this.getCollection(container).set(id, updated); + return updated; + } + + async delete(container: string, id: string, productId: string): Promise { + const existing = await this.get(container, id, productId); + if (!existing) return false; + this.getCollection(container).delete(id); + return true; + } +} + +// ── Route generator ────────────────────────────────────────────────────── + +/** + * Register CRUD routes for a single declarative module definition. + */ +export function registerModuleRoutes( + app: FastifyInstance, + mod: ModuleDef, + store: MemoryStore +): void { + const createSchema = buildCreateSchema(mod); + const updateSchema = buildUpdateSchema(mod); + const endpoints = + mod.endpoints && mod.endpoints.length > 0 ? mod.endpoints : defaultEndpoints(mod); + + for (const ep of endpoints) { + if (ep.custom) continue; // Skip custom — those need hand-written handlers + + const handler = buildHandler(ep, mod, store, createSchema, updateSchema); + const method = ep.method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete'; + app[method](ep.path, handler); + } +} + +function buildHandler( + ep: EndpointDef, + mod: ModuleDef, + store: MemoryStore, + createSchema: ReturnType, + updateSchema: ReturnType +): (req: FastifyRequest, reply: FastifyReply) => Promise { + const { method, path, auth } = ep; + const isById = path.includes(':id'); + + // Determine auth function + const checkAuth = auth === 'admin' ? requireAdmin : auth === 'user' ? getUserId : null; + + // ── LIST ──────────────────────────────────────────────────────── + if (method === 'GET' && !isById) { + return async (req, _reply) => { + if (checkAuth) checkAuth(req); + const productId = getProductId(req); + const query = req.query as Record; + const sortBy = query.sortBy || 'createdAt'; + const sortDir = query.sortDir === 'asc' ? 'asc' : 'desc'; + const limit = Math.min(parseInt(query.limit || '50', 10), 100); + const offset = parseInt(query.offset || '0', 10); + + // Extract field-based filters + const filters: Record = {}; + for (const key of Object.keys(mod.fields)) { + if (query[key] !== undefined) { + filters[key] = query[key]; + } + } + + return store.list(mod.container, productId, { sortBy, sortDir, limit, offset, filters }); + }; + } + + // ── GET BY ID ─────────────────────────────────────────────────── + if (method === 'GET' && isById) { + return async (req, _reply) => { + if (checkAuth) checkAuth(req); + const productId = getProductId(req); + const { id } = req.params as { id: string }; + const doc = await store.get(mod.container, id, productId); + if (!doc) throw Object.assign(new Error(`${mod.name} not found`), { statusCode: 404 }); + return doc; + }; + } + + // ── CREATE ────────────────────────────────────────────────────── + if (method === 'POST') { + return async (req, reply) => { + let userId: string | undefined; + if (checkAuth) userId = checkAuth(req); + const productId = getProductId(req); + const input = createSchema.parse(req.body); + const now = new Date().toISOString(); + const prefix = mod.idPrefix ? `${mod.idPrefix}_` : ''; + const doc: Record = { + id: `${prefix}${crypto.randomUUID()}`, + productId, + ...input, + createdAt: now, + updatedAt: now, + }; + if (userId) doc.createdBy = userId; + const created = await store.create(mod.container, doc); + reply.status(201); + return created; + }; + } + + // ── UPDATE ────────────────────────────────────────────────────── + if (method === 'PATCH' || method === 'PUT') { + return async (req, _reply) => { + if (checkAuth) checkAuth(req); + const productId = getProductId(req); + const { id } = req.params as { id: string }; + const updates = updateSchema.parse(req.body); + const updated = await store.update( + mod.container, + id, + productId, + updates as Record + ); + if (!updated) throw Object.assign(new Error(`${mod.name} not found`), { statusCode: 404 }); + return updated; + }; + } + + // ── DELETE ────────────────────────────────────────────────────── + if (method === 'DELETE') { + return async (req, reply) => { + if (checkAuth) checkAuth(req); + const productId = getProductId(req); + const { id } = req.params as { id: string }; + const ok = await store.delete(mod.container, id, productId); + if (!ok) throw Object.assign(new Error(`${mod.name} not found`), { statusCode: 404 }); + reply.status(204); + }; + } + + // Fallback — shouldn't reach here + return async () => ({ error: 'Unhandled endpoint' }); +} + +// ── YAML file loader ───────────────────────────────────────────────────── + +/** + * Parse a YAML string into a validated ModuleDef. + */ +export function parseModuleYaml(yamlContent: string): ModuleDef { + const raw = parseYaml(yamlContent); + return ModuleSchema.parse(raw); +} + +/** + * Load all .module.yaml files from a directory and register their routes. + * Returns the list of loaded module definitions. + */ +export async function loadDeclarativeModules( + app: FastifyInstance, + dir: string, + store?: MemoryStore +): Promise { + const memStore = store ?? new MemoryStore(); + const modules: ModuleDef[] = []; + + let files: string[]; + try { + files = await readdir(dir); + } catch { + // Directory doesn't exist — no declarative modules + return modules; + } + + const yamlFiles = files.filter(f => f.endsWith('.module.yaml')); + + for (const file of yamlFiles) { + const content = await readFile(join(dir, file), 'utf-8'); + const mod = parseModuleYaml(content); + registerModuleRoutes(app, mod, memStore); + modules.push(mod); + app.log.info(`Loaded declarative module: ${mod.name} (${file})`); + } + + return modules; +} diff --git a/services/platform-service/src/lib/declarative-schema.ts b/services/platform-service/src/lib/declarative-schema.ts new file mode 100644 index 00000000..9e8126e6 --- /dev/null +++ b/services/platform-service/src/lib/declarative-schema.ts @@ -0,0 +1,167 @@ +/** + * Declarative module YAML schema definition and validation. + * + * Defines the structure of .module.yaml files that produce + * runtime-generated Fastify CRUD routes with no TypeScript needed. + */ + +import { z } from 'zod'; + +// ── Field types supported in YAML definitions ───────────────────────────── + +const FieldTypeEnum = z.enum(['string', 'number', 'boolean', 'date', 'array', 'enum']); + +// ── Field definition ────────────────────────────────────────────────────── + +export const FieldSchema = z.object({ + type: FieldTypeEnum, + required: z.boolean().default(false), + default: z.any().optional(), + min: z.number().optional(), + max: z.number().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + values: z.array(z.string()).optional(), // for enum type + items: z.string().optional(), // for array type (item type) + description: z.string().optional(), +}); + +// ── Endpoint definition ─────────────────────────────────────────────────── + +export const EndpointSchema = z.object({ + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), + path: z.string().min(1), + auth: z.enum(['none', 'user', 'admin']).default('user'), + custom: z.boolean().default(false), + description: z.string().optional(), +}); + +// ── Module definition (top-level YAML structure) ────────────────────────── + +export const ModuleSchema = z.object({ + name: z.string().min(1).max(100), + container: z.string().min(1).max(100), + partitionKey: z.string().min(1).max(100).default('/productId'), + idPrefix: z.string().max(20).default(''), + fields: z.record(z.string(), FieldSchema), + endpoints: z.array(EndpointSchema).optional(), + description: z.string().optional(), +}); + +// ── Inferred types ──────────────────────────────────────────────────────── + +export type FieldDef = z.infer; +export type EndpointDef = z.infer; +export type ModuleDef = z.infer; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/** + * Convert a FieldDef to a Zod schema for runtime validation. + */ +export function fieldToZodSchema(name: string, field: FieldDef): z.ZodTypeAny { + let schema: z.ZodTypeAny; + + switch (field.type) { + case 'string': { + let s = z.string(); + if (field.minLength !== undefined) s = s.min(field.minLength); + if (field.maxLength !== undefined) s = s.max(field.maxLength); + schema = s; + break; + } + case 'number': { + let n = z.number(); + if (field.min !== undefined) n = n.min(field.min); + if (field.max !== undefined) n = n.max(field.max); + schema = n; + break; + } + case 'boolean': + schema = z.boolean(); + break; + case 'date': + schema = z.string().datetime({ offset: true }).or(z.string().datetime()); + break; + case 'enum': + if (!field.values || field.values.length === 0) { + throw new Error(`Field "${name}" is enum but has no values`); + } + schema = z.enum(field.values as [string, ...string[]]); + break; + case 'array': + schema = z.array(z.any()); + break; + default: + schema = z.any(); + } + + if (field.default !== undefined) { + // .default() already handles undefined → value, so no need for .optional() + schema = schema.default(field.default); + } else if (!field.required) { + schema = schema.optional(); + } + + return schema; +} + +/** + * Build a Zod object schema for create operations from a ModuleDef. + */ +export function buildCreateSchema(mod: ModuleDef): z.ZodObject> { + const shape: Record = {}; + for (const [name, field] of Object.entries(mod.fields)) { + shape[name] = fieldToZodSchema(name, field); + } + return z.object(shape); +} + +/** + * Build a Zod object schema for update operations (all fields optional). + */ +export function buildUpdateSchema(mod: ModuleDef): z.ZodObject> { + const shape: Record = {}; + for (const [name, field] of Object.entries(mod.fields)) { + // For updates, make everything optional + let schema = fieldToZodSchema(name, { ...field, required: false }); + // Remove default on updates + if (field.default !== undefined) { + schema = fieldToZodSchema(name, { ...field, required: false, default: undefined }); + } + shape[name] = schema; + } + return z.object(shape); +} + +/** + * Generate default CRUD endpoints for a module if none are specified. + */ +export function defaultEndpoints(mod: ModuleDef): EndpointDef[] { + const base = `/${mod.name}`; + return [ + { method: 'GET', path: base, auth: 'user', custom: false, description: `List ${mod.name}` }, + { + method: 'GET', + path: `${base}/:id`, + auth: 'user', + custom: false, + description: `Get ${mod.name} by ID`, + }, + { method: 'POST', path: base, auth: 'user', custom: false, description: `Create ${mod.name}` }, + { + method: 'PATCH', + path: `${base}/:id`, + auth: 'user', + custom: false, + description: `Update ${mod.name}`, + }, + { + method: 'DELETE', + path: `${base}/:id`, + auth: 'admin', + custom: false, + description: `Delete ${mod.name}`, + }, + ]; +}