rotifex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1958 -0
  3. package/bin/rotifex.js +18 -0
  4. package/config.default.json +29 -0
  5. package/package.json +48 -0
  6. package/src/ai/agent.routes.js +110 -0
  7. package/src/ai/agent.service.js +326 -0
  8. package/src/ai/agents.config.js +66 -0
  9. package/src/ai/ai.config.js +84 -0
  10. package/src/ai/ai.routes.js +149 -0
  11. package/src/ai/ai.service.js +275 -0
  12. package/src/ai/ai.usage.js +58 -0
  13. package/src/ai/tools/registry.js +156 -0
  14. package/src/auth/auth.controller.js +118 -0
  15. package/src/auth/auth.routes.js +27 -0
  16. package/src/auth/auth.service.js +182 -0
  17. package/src/auth/jwt.middleware.js +41 -0
  18. package/src/auth/password.util.js +6 -0
  19. package/src/commands/init.js +30 -0
  20. package/src/commands/migrate.js +62 -0
  21. package/src/commands/start.js +96 -0
  22. package/src/db/adapters/base.js +73 -0
  23. package/src/db/adapters/sqlite.js +86 -0
  24. package/src/db/connection.js +45 -0
  25. package/src/db/index.js +12 -0
  26. package/src/db/migrator.js +142 -0
  27. package/src/db/schema.js +48 -0
  28. package/src/engine/index.js +76 -0
  29. package/src/engine/queryBuilder.js +52 -0
  30. package/src/engine/routeFactory.js +119 -0
  31. package/src/engine/schemaLoader.js +75 -0
  32. package/src/engine/schemaStore.js +35 -0
  33. package/src/engine/tableSync.js +28 -0
  34. package/src/engine/zodFactory.js +54 -0
  35. package/src/lib/config.js +120 -0
  36. package/src/lib/logBuffer.js +75 -0
  37. package/src/lib/logger.js +26 -0
  38. package/src/server/index.js +8 -0
  39. package/src/server/middleware/errorHandler.js +26 -0
  40. package/src/server/middleware/requestLogger.js +23 -0
  41. package/src/server/plugins.js +38 -0
  42. package/src/server/routes/admin.js +276 -0
  43. package/src/server/routes/files.js +188 -0
  44. package/src/server/routes/health.js +14 -0
  45. package/src/server/server.js +149 -0
  46. package/src/storage/fileTable.js +22 -0
  47. package/src/storage/index.js +5 -0
  48. package/src/storage/storageManager.js +225 -0
@@ -0,0 +1,276 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { loadSchema, parseModelDef } from '../../engine/schemaLoader.js';
4
+ import { getProviders } from '../../ai/ai.config.js';
5
+ import { listAgents } from '../../ai/agents.config.js';
6
+ import { getUsageSummary } from '../../ai/ai.usage.js';
7
+ import { upsertModel, removeModel } from '../../engine/schemaStore.js';
8
+ import { syncTables } from '../../engine/tableSync.js';
9
+ import { getLogs } from '../../lib/logBuffer.js';
10
+
11
+ // ── .env file helpers ─────────────────────────────────────────────────────────
12
+
13
+ function readEnvFile() {
14
+ const abs = resolve('.env');
15
+ if (!existsSync(abs)) return {};
16
+ const vars = {};
17
+ for (const line of readFileSync(abs, 'utf-8').split('\n')) {
18
+ const trimmed = line.trim();
19
+ if (!trimmed || trimmed.startsWith('#')) continue;
20
+ const eq = trimmed.indexOf('=');
21
+ if (eq === -1) continue;
22
+ const key = trimmed.slice(0, eq).trim();
23
+ const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
24
+ vars[key] = val;
25
+ }
26
+ return vars;
27
+ }
28
+
29
+ function writeEnvFile(vars) {
30
+ const lines = Object.entries(vars)
31
+ .filter(([, v]) => v !== '' && v !== null && v !== undefined)
32
+ .map(([k, v]) => `${k}=${String(v).includes(' ') ? `"${v}"` : v}`);
33
+ writeFileSync(resolve('.env'), lines.join('\n') + '\n');
34
+ }
35
+
36
+ /**
37
+ * Admin-only API routes, registered under `/admin/api`.
38
+ *
39
+ * All routes are guarded by an `onRequest` hook that checks
40
+ * `x-user-role: admin`.
41
+ *
42
+ * @param {import('fastify').FastifyInstance} app
43
+ * @param {{ db: import('../../db/adapters/base.js').DatabaseAdapter }} opts
44
+ */
45
+ export async function adminRoutes(app, { db }) {
46
+
47
+ // ── Admin guard ─────────────────────────────────────────────────────
48
+ app.addHook('onRequest', async (request, reply) => {
49
+ const role = request.headers['x-user-role'];
50
+ if (role !== 'admin') {
51
+ reply.status(403).send({
52
+ error: 'Forbidden',
53
+ message: 'Admin access required',
54
+ statusCode: 403,
55
+ });
56
+ }
57
+ });
58
+
59
+ // ── GET /admin/api/schema ─────────────────────────────────────────
60
+ app.get('/admin/api/schema', () => {
61
+ const models = loadSchema();
62
+ const result = {};
63
+ for (const [name, model] of models) {
64
+ result[name] = model;
65
+ }
66
+ return { data: result };
67
+ });
68
+
69
+ // ── GET /admin/api/stats ──────────────────────────────────────────
70
+ app.get('/admin/api/stats', () => {
71
+ const models = loadSchema();
72
+ const modelStats = [];
73
+
74
+ for (const [name, model] of models) {
75
+ const row = db.get(`SELECT COUNT(*) AS count FROM ${model.tableName}`);
76
+ modelStats.push({
77
+ model: name,
78
+ table: model.tableName,
79
+ count: row?.count ?? 0,
80
+ });
81
+ }
82
+
83
+ // User stats
84
+ let userCount = 0;
85
+ try {
86
+ const userRow = db.get('SELECT COUNT(*) AS count FROM users');
87
+ userCount = userRow?.count ?? 0;
88
+ } catch {}
89
+
90
+ // File stats
91
+ let fileCount = 0;
92
+ let storageBytes = 0;
93
+ try {
94
+ const fileRow = db.get('SELECT COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS total FROM _files');
95
+ fileCount = fileRow?.count ?? 0;
96
+ storageBytes = fileRow?.total ?? 0;
97
+ } catch {
98
+ // _files table may not exist yet
99
+ }
100
+
101
+ // AI stats
102
+ const providers = getProviders();
103
+ const connectedLLMs = Object.values(providers).filter(p => p.enabled && p.apiKey).length;
104
+ const enabledLLMs = Object.values(providers).filter(p => p.enabled).length;
105
+ const providerList = Object.entries(providers)
106
+ .filter(([, p]) => p.enabled)
107
+ .map(([id, p]) => ({ id, label: p.label, hasKey: !!p.apiKey }));
108
+
109
+ const agentList = listAgents();
110
+ const usageSummary = getUsageSummary();
111
+
112
+ return {
113
+ data: {
114
+ models: modelStats,
115
+ users: { count: userCount },
116
+ files: { count: fileCount, storageMB: +(storageBytes / 1024 / 1024).toFixed(2) },
117
+ uptime: process.uptime(),
118
+ ai: {
119
+ connectedLLMs,
120
+ enabledLLMs,
121
+ providers: providerList,
122
+ agentsCount: agentList.length,
123
+ agents: agentList.map(a => ({ id: a.id, name: a.name, provider: a.provider, model: a.model, tools: a.tools })),
124
+ usage: usageSummary,
125
+ },
126
+ },
127
+ };
128
+ });
129
+
130
+ // ── GET /admin/api/logs ───────────────────────────────────────────
131
+ app.get('/admin/api/logs', (request) => {
132
+ const { after, level } = request.query;
133
+ return getLogs({
134
+ after: after ? Number(after) : undefined,
135
+ level: level || undefined,
136
+ });
137
+ });
138
+
139
+ const RESERVED = new Set(['user', 'users', '_files', 'files']);
140
+ const SYSTEM_MODELS = new Set(['User']);
141
+
142
+ // ── POST /admin/api/schema — create or replace a model ───────────
143
+ app.post('/admin/api/schema', (request, reply) => {
144
+ const { name, fields } = request.body || {};
145
+ if (!name || !fields || typeof fields !== 'object') {
146
+ return reply.status(400).send({
147
+ error: 'Bad Request',
148
+ message: 'name (string) and fields (object) are required',
149
+ statusCode: 400,
150
+ });
151
+ }
152
+
153
+ if (RESERVED.has(name.toLowerCase())) {
154
+ return reply.status(400).send({
155
+ error: 'Bad Request',
156
+ message: `"${name}" is a reserved name and cannot be used as a model.`,
157
+ statusCode: 400,
158
+ });
159
+ }
160
+
161
+ // 1. Persist to schema.json
162
+ const schemaPath = resolve('schema.json');
163
+ let schema = {};
164
+ if (existsSync(schemaPath)) {
165
+ try { schema = JSON.parse(readFileSync(schemaPath, 'utf-8')); } catch {}
166
+ }
167
+
168
+ if (schema[name]) {
169
+ return reply.status(409).send({
170
+ error: 'Conflict',
171
+ message: `Model "${name}" already exists. Delete it first if you want to redefine it.`,
172
+ statusCode: 409,
173
+ });
174
+ }
175
+
176
+ schema[name] = { fields };
177
+ writeFileSync(schemaPath, JSON.stringify(schema, null, 2));
178
+
179
+ // 2. Parse into normalised model definition
180
+ const modelDef = parseModelDef(name, { fields });
181
+
182
+ // 3. Create DB table if it doesn't exist yet (idempotent)
183
+ syncTables(db, new Map([[name, modelDef]]));
184
+
185
+ // 4. Add to live store — routes are active immediately
186
+ upsertModel(name, modelDef);
187
+
188
+ return reply.status(201).send({
189
+ data: { name, tableName: modelDef.tableName, fields: modelDef.fields },
190
+ message: `Model "${name}" is live. Routes /${modelDef.tableName} are active now.`,
191
+ });
192
+ });
193
+
194
+ // ── DELETE /admin/api/schema/:name — remove a model ──────────────
195
+ app.delete('/admin/api/schema/:name', (request, reply) => {
196
+ const { name } = request.params;
197
+ const schemaPath = resolve('schema.json');
198
+ let schema = {};
199
+ if (existsSync(schemaPath)) {
200
+ try { schema = JSON.parse(readFileSync(schemaPath, 'utf-8')); } catch {}
201
+ }
202
+
203
+ if (SYSTEM_MODELS.has(name)) {
204
+ return reply.status(400).send({
205
+ error: 'Bad Request',
206
+ message: `"${name}" is a system model and cannot be deleted.`,
207
+ statusCode: 400,
208
+ });
209
+ }
210
+
211
+ if (!schema[name]) {
212
+ return reply.status(404).send({
213
+ error: 'Not Found',
214
+ message: `Model "${name}" not found`,
215
+ statusCode: 404,
216
+ });
217
+ }
218
+
219
+ // 1. Remove from schema.json
220
+ delete schema[name];
221
+ writeFileSync(schemaPath, JSON.stringify(schema, null, 2));
222
+
223
+ // 2. Remove from live store — routes stop resolving immediately
224
+ removeModel(name);
225
+
226
+ return reply.status(204).send();
227
+ });
228
+
229
+ // ── GET /admin/api/env — read current .env values ─────────────────
230
+ app.get('/admin/api/env', () => {
231
+ const fileVars = readEnvFile();
232
+ // Merge: file values take precedence over process.env for display,
233
+ // but fall back to process.env so already-set vars are visible too.
234
+ const merged = {};
235
+ for (const key of ENV_KEYS) {
236
+ merged[key] = fileVars[key] ?? process.env[key] ?? '';
237
+ }
238
+ return { data: merged };
239
+ });
240
+
241
+ // ── POST /admin/api/env — write .env file ─────────────────────────
242
+ app.post('/admin/api/env', (request, reply) => {
243
+ const incoming = request.body?.vars;
244
+ if (!incoming || typeof incoming !== 'object') {
245
+ return reply.status(400).send({ error: 'Bad Request', message: 'vars (object) is required', statusCode: 400 });
246
+ }
247
+
248
+ // Only allow known keys — never let arbitrary keys be written
249
+ const existing = readEnvFile();
250
+ for (const key of ENV_KEYS) {
251
+ if (incoming[key] !== undefined) {
252
+ if (incoming[key] === '') {
253
+ delete existing[key]; // empty = remove the key
254
+ } else {
255
+ existing[key] = incoming[key];
256
+ }
257
+ }
258
+ }
259
+
260
+ writeEnvFile(existing);
261
+ return { message: 'Environment saved. Restart the server for changes to take effect.' };
262
+ });
263
+ }
264
+
265
+ // Keys the settings UI is allowed to read/write.
266
+ const ENV_KEYS = [
267
+ 'JWT_SECRET',
268
+ 'JWT_REFRESH_SECRET',
269
+ 'ROTIFEX_PORT',
270
+ 'ROTIFEX_HOST',
271
+ 'ROTIFEX_CORS_ORIGIN',
272
+ 'ROTIFEX_RATE_LIMIT_MAX',
273
+ 'ROTIFEX_LOG_LEVEL',
274
+ 'ROTIFEX_STORAGE_MAX_FILE_SIZE_MB',
275
+ 'ROTIFEX_STORAGE_SIGNED_URL_SECRET',
276
+ ];
@@ -0,0 +1,188 @@
1
+ import { createReadStream } from 'node:fs';
2
+
3
+ /**
4
+ * File upload / download routes.
5
+ *
6
+ * Registered under `/files`. Uses header-based identity for the MVP
7
+ * (`x-user-id` and `x-user-role`) since Rotifex does not yet have an
8
+ * auth layer.
9
+ *
10
+ * @param {import('fastify').FastifyInstance} app
11
+ * @param {{ storage: import('../../storage/storageManager.js').StorageManager, config: object }} opts
12
+ */
13
+ export async function fileRoutes(app, { storage, config }) {
14
+
15
+ // ── Helper: extract identity from headers ─────────────────────────
16
+ function getIdentity(request) {
17
+ return {
18
+ userId: request.headers['x-user-id'] || 'anonymous',
19
+ role: request.headers['x-user-role'] || 'user',
20
+ };
21
+ }
22
+
23
+ // ── Helper: ownership / role gate ─────────────────────────────────
24
+ function assertAccess(meta, identity, reply) {
25
+ if (identity.role === 'admin') return true;
26
+ if (meta.uploader_id === identity.userId) return true;
27
+ reply.status(403).send({
28
+ error: 'Forbidden',
29
+ message: 'You do not have access to this file',
30
+ statusCode: 403,
31
+ });
32
+ return false;
33
+ }
34
+
35
+ // ── UPLOAD ────────────────────────────────────────────────────────
36
+ app.post('/files/upload', async (request, reply) => {
37
+ const identity = getIdentity(request);
38
+
39
+ const data = await request.file();
40
+ if (!data) {
41
+ return reply.status(400).send({
42
+ error: 'Bad Request',
43
+ message: 'No file provided. Send a multipart form with a "file" field.',
44
+ statusCode: 400,
45
+ });
46
+ }
47
+
48
+ // Consume the file stream into a buffer
49
+ const chunks = [];
50
+ for await (const chunk of data.file) {
51
+ chunks.push(chunk);
52
+ }
53
+ const buffer = Buffer.concat(chunks);
54
+
55
+ // Check max file size
56
+ const maxBytes = (config.storage.maxFileSizeMB || 10) * 1024 * 1024;
57
+ if (buffer.length > maxBytes) {
58
+ return reply.status(413).send({
59
+ error: 'Payload Too Large',
60
+ message: `File exceeds the ${config.storage.maxFileSizeMB} MB limit`,
61
+ statusCode: 413,
62
+ });
63
+ }
64
+
65
+ const visibility = data.fields?.visibility?.value || 'public';
66
+ if (!['public', 'private'].includes(visibility)) {
67
+ return reply.status(400).send({
68
+ error: 'Bad Request',
69
+ message: 'visibility must be "public" or "private"',
70
+ statusCode: 400,
71
+ });
72
+ }
73
+
74
+ const fileMeta = storage.save(
75
+ { filename: data.filename, mimetype: data.mimetype, data: buffer },
76
+ { visibility, uploaderId: identity.userId },
77
+ );
78
+
79
+ return reply.status(201).send({ data: fileMeta });
80
+ });
81
+
82
+ // ── LIST FILES ────────────────────────────────────────────────────
83
+ app.get('/files', (request, reply) => {
84
+ const identity = getIdentity(request);
85
+ const filters = identity.role === 'admin'
86
+ ? {}
87
+ : { uploaderId: identity.userId };
88
+
89
+ const files = storage.listFiles(filters);
90
+ return { data: files, meta: { total: files.length } };
91
+ });
92
+
93
+ // ── GET FILE METADATA ─────────────────────────────────────────────
94
+ app.get('/files/:id', (request, reply) => {
95
+ const identity = getIdentity(request);
96
+ const meta = storage.getFileMeta(request.params.id);
97
+ if (!meta) {
98
+ return reply.status(404).send({
99
+ error: 'Not Found',
100
+ message: 'File not found',
101
+ statusCode: 404,
102
+ });
103
+ }
104
+
105
+ if (!assertAccess(meta, identity, reply)) return;
106
+ return { data: meta };
107
+ });
108
+
109
+ // ── DOWNLOAD ──────────────────────────────────────────────────────
110
+ app.get('/files/:id/download', (request, reply) => {
111
+ const meta = storage.getFileMeta(request.params.id);
112
+ if (!meta) {
113
+ return reply.status(404).send({
114
+ error: 'Not Found',
115
+ message: 'File not found',
116
+ statusCode: 404,
117
+ });
118
+ }
119
+
120
+ // Private files require a valid signed URL
121
+ if (meta.visibility === 'private') {
122
+ const { token, expires } = request.query;
123
+ if (!token || !expires) {
124
+ return reply.status(403).send({
125
+ error: 'Forbidden',
126
+ message: 'Private files require a signed URL. Use GET /files/:id/signed-url to obtain one.',
127
+ statusCode: 403,
128
+ });
129
+ }
130
+ if (!storage.verifySignedUrl(meta.id, token, expires)) {
131
+ return reply.status(403).send({
132
+ error: 'Forbidden',
133
+ message: 'Invalid or expired signed URL',
134
+ statusCode: 403,
135
+ });
136
+ }
137
+ }
138
+
139
+ const filepath = storage.getFilePath(meta);
140
+ reply.header('Content-Type', meta.mime_type);
141
+ reply.header('Content-Disposition', `inline; filename="${meta.original_name}"`);
142
+ return reply.send(createReadStream(filepath));
143
+ });
144
+
145
+ // ── GENERATE SIGNED URL ───────────────────────────────────────────
146
+ app.get('/files/:id/signed-url', (request, reply) => {
147
+ const identity = getIdentity(request);
148
+ const meta = storage.getFileMeta(request.params.id);
149
+ if (!meta) {
150
+ return reply.status(404).send({
151
+ error: 'Not Found',
152
+ message: 'File not found',
153
+ statusCode: 404,
154
+ });
155
+ }
156
+
157
+ if (!assertAccess(meta, identity, reply)) return;
158
+
159
+ if (meta.visibility !== 'private') {
160
+ return reply.status(400).send({
161
+ error: 'Bad Request',
162
+ message: 'Signed URLs are only for private files',
163
+ statusCode: 400,
164
+ });
165
+ }
166
+
167
+ const signed = storage.generateSignedUrl(meta.id, request);
168
+ return { data: signed };
169
+ });
170
+
171
+ // ── DELETE ────────────────────────────────────────────────────────
172
+ app.delete('/files/:id', (request, reply) => {
173
+ const identity = getIdentity(request);
174
+ const meta = storage.getFileMeta(request.params.id);
175
+ if (!meta) {
176
+ return reply.status(404).send({
177
+ error: 'Not Found',
178
+ message: 'File not found',
179
+ statusCode: 404,
180
+ });
181
+ }
182
+
183
+ if (!assertAccess(meta, identity, reply)) return;
184
+
185
+ storage.deleteFile(meta.id);
186
+ return reply.status(204).send();
187
+ });
188
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Health-check route.
3
+ *
4
+ * GET /health → { status, uptime, timestamp }
5
+ *
6
+ * @param {import('fastify').FastifyInstance} app
7
+ */
8
+ export async function healthRoutes(app) {
9
+ app.get('/health', async () => ({
10
+ status: 'ok',
11
+ uptime: process.uptime(),
12
+ timestamp: new Date().toISOString(),
13
+ }));
14
+ }
@@ -0,0 +1,149 @@
1
+ import { mkdirSync, existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join } from 'node:path';
5
+ import Fastify from 'fastify';
6
+ import { registerPlugins } from './plugins.js';
7
+ import { registerRequestLogger } from './middleware/requestLogger.js';
8
+ import { registerErrorHandler } from './middleware/errorHandler.js';
9
+ import { healthRoutes } from './routes/health.js';
10
+ import { fileRoutes } from './routes/files.js';
11
+ import { adminRoutes } from './routes/admin.js';
12
+ import { authRoutes } from '../auth/auth.routes.js';
13
+ import { aiRoutes } from '../ai/ai.routes.js';
14
+ import { agentRoutes } from '../ai/agent.routes.js';
15
+ import { registerJwtMiddleware } from '../auth/jwt.middleware.js';
16
+ import { getDatabase } from '../db/index.js';
17
+ import { bootstrapEngine } from '../engine/index.js';
18
+ import { StorageManager } from '../storage/index.js';
19
+ import { logBufferStream } from '../lib/logBuffer.js';
20
+ import { logger } from '../lib/logger.js';
21
+
22
+ const __filename = fileURLToPath(import.meta.url);
23
+ const __dirname = dirname(__filename);
24
+
25
+ /**
26
+ * Create and configure a Fastify server instance.
27
+ *
28
+ * @param {object} config Merged Rotifex config (from `loadConfig()`).
29
+ * @returns {Promise<import('fastify').FastifyInstance>}
30
+ */
31
+ export async function createServer(config) {
32
+ // ── Ensure storage directories exist BEFORE plugin registration ────
33
+ for (const dir of [config.storage?.publicDir, config.storage?.privateDir]) {
34
+ if (dir && !existsSync(dir)) {
35
+ mkdirSync(dir, { recursive: true });
36
+ }
37
+ }
38
+
39
+ const app = Fastify({
40
+ logger: {
41
+ level: config.logging.level,
42
+ stream: {
43
+ write(msg) {
44
+ // Only feed the in-memory buffer (admin Logs page).
45
+ // Nothing goes to the terminal — startup messages use the chalk
46
+ // logger directly and are already printed there.
47
+ logBufferStream.write(msg);
48
+ },
49
+ },
50
+ },
51
+ disableRequestLogging: true,
52
+ });
53
+
54
+ // ── Plugins ─────────────────────────────────────────────────────────
55
+ await registerPlugins(app, config);
56
+
57
+ // ── Middleware ───────────────────────────────────────────────────────
58
+ registerRequestLogger(app);
59
+ registerErrorHandler(app);
60
+ registerJwtMiddleware(app); // verifies Bearer tokens, injects x-user-id / x-user-role
61
+
62
+ // ── Routes ──────────────────────────────────────────────────────────
63
+ await app.register(healthRoutes);
64
+
65
+ // ── Database + Auth (auth routes registered before engine so /auth/* ─
66
+ // ── is a concrete path and always wins over parametric /api/:table) ─
67
+ const db = getDatabase();
68
+ await app.register(authRoutes, { db });
69
+
70
+ // ── Dynamic REST Engine ─────────────────────────────────────────────
71
+ bootstrapEngine(app, db);
72
+
73
+ // ── File Storage ────────────────────────────────────────────────────
74
+ const storage = new StorageManager(db, config.storage);
75
+ storage.init();
76
+ await app.register(fileRoutes, { storage, config });
77
+
78
+ // ── AI Routes ───────────────────────────────────────────────────────
79
+ await app.register(aiRoutes);
80
+ await app.register(agentRoutes, { db });
81
+
82
+ // ── Admin API ───────────────────────────────────────────────────────
83
+ await app.register(adminRoutes, { db });
84
+
85
+ // ── SPA (served at /) ────────────────────────────────────────────────
86
+ // Resolve admin/dist from the package directory so it works when installed via npx/npm
87
+ const adminDist = join(__dirname, '../../admin/dist');
88
+ if (existsSync(adminDist)) {
89
+ const fastifyStatic = (await import('@fastify/static')).default;
90
+ await app.register(fastifyStatic, {
91
+ root: adminDist,
92
+ prefix: '/',
93
+ wildcard: false,
94
+ });
95
+ logger.success('Dashboard is live at / — go click things!');
96
+ } else {
97
+ logger.warn('Admin dashboard not built yet. Run "npm run build:admin" first.');
98
+ }
99
+
100
+ // ── 404 handler ──────────────────────────────────────────────────────
101
+ app.setNotFoundHandler((request, reply) => {
102
+ reply.status(404).type('text/html').send(`<!DOCTYPE html>
103
+ <html lang="en">
104
+ <head>
105
+ <meta charset="UTF-8" />
106
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
107
+ <title>404 — Rotifex</title>
108
+ <style>
109
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
110
+ body {
111
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
112
+ background: #f5f6f8; color: #1a1d23;
113
+ display: flex; align-items: center; justify-content: center; min-height: 100vh;
114
+ }
115
+ .box {
116
+ text-align: center; padding: 48px 40px; background: #fff;
117
+ border: 1px solid #e2e5ea; border-radius: 12px;
118
+ box-shadow: 0 4px 24px rgba(0,0,0,0.06); max-width: 420px; width: 100%;
119
+ }
120
+ .code { font-size: 72px; font-weight: 800; color: #4f6ef7; letter-spacing: -2px; line-height: 1; }
121
+ .title { font-size: 20px; font-weight: 600; margin: 12px 0 8px; }
122
+ .path {
123
+ font-family: 'SF Mono', monospace; font-size: 13px;
124
+ background: #f3f4f6; color: #6b7280; padding: 4px 10px;
125
+ border-radius: 4px; display: inline-block; margin-bottom: 28px;
126
+ }
127
+ a {
128
+ display: inline-block; padding: 10px 24px; background: #4f6ef7;
129
+ color: #fff; text-decoration: none; border-radius: 8px;
130
+ font-size: 14px; font-weight: 500; transition: background 0.15s;
131
+ }
132
+ a:hover { background: #3b5de7; }
133
+ </style>
134
+ </head>
135
+ <body>
136
+ <div class="box">
137
+ <div class="code">404</div>
138
+ <div class="title">Nothing here</div>
139
+ <div class="path">${request.url}</div>
140
+ <a href="/">← Back to Dashboard</a>
141
+ </div>
142
+ </body>
143
+ </html>`);
144
+ });
145
+
146
+ logger.success('File storage is up — ready to hoard your files.');
147
+
148
+ return app;
149
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Ensure the `_files` metadata table exists.
3
+ *
4
+ * Uses the `_` prefix to avoid collisions with user-defined models
5
+ * generated from `schema.json`.
6
+ *
7
+ * @param {import('../db/adapters/base.js').DatabaseAdapter} db
8
+ */
9
+ export function ensureFileTable(db) {
10
+ db.exec(`
11
+ CREATE TABLE IF NOT EXISTS _files (
12
+ id TEXT PRIMARY KEY,
13
+ original_name TEXT NOT NULL,
14
+ stored_name TEXT NOT NULL,
15
+ mime_type TEXT NOT NULL,
16
+ size_bytes INTEGER NOT NULL,
17
+ visibility TEXT NOT NULL DEFAULT 'public',
18
+ uploader_id TEXT NOT NULL,
19
+ created_at TEXT NOT NULL
20
+ )
21
+ `);
22
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Public API for the Rotifex storage layer.
3
+ */
4
+ export { StorageManager } from './storageManager.js';
5
+ export { ensureFileTable } from './fileTable.js';