phantomback 1.0.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.
@@ -0,0 +1,138 @@
1
+ import { writeFileSync, existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { logger } from '../utils/logger.js';
4
+
5
+ /**
6
+ * CLI command: phantomback init
7
+ * Generates a starter phantom.config.js in the CWD
8
+ */
9
+ export async function initCommand() {
10
+ const configPath = resolve(process.cwd(), 'phantom.config.js');
11
+
12
+ if (existsSync(configPath)) {
13
+ logger.warn('phantom.config.js already exists. Skipping.');
14
+ return;
15
+ }
16
+
17
+ const template = `// PhantomBack Configuration
18
+ // Docs: https://github.com/phantomback/phantomback
19
+
20
+ export default {
21
+ port: 3777,
22
+ prefix: '/api',
23
+
24
+ // Global response latency (ms). Use [min, max] for random range.
25
+ // latency: 0,
26
+ // latency: [200, 800],
27
+
28
+ auth: {
29
+ enabled: true,
30
+ secret: 'my-super-secret-key',
31
+ expiresIn: '24h',
32
+ },
33
+
34
+ resources: {
35
+ users: {
36
+ fields: {
37
+ name: { type: 'name', required: true },
38
+ email: { type: 'email', unique: true },
39
+ username: { type: 'username' },
40
+ avatar: { type: 'avatar' },
41
+ age: { type: 'number', min: 18, max: 65 },
42
+ role: { type: 'enum', values: ['admin', 'user', 'moderator'] },
43
+ },
44
+ seed: 25, // Auto-generate 25 fake records
45
+ auth: true, // Protect CRUD with JWT token
46
+ },
47
+
48
+ posts: {
49
+ fields: {
50
+ title: { type: 'title', required: true },
51
+ body: { type: 'paragraphs', count: 3 },
52
+ slug: { type: 'slug' },
53
+ image: { type: 'image' },
54
+ published: { type: 'boolean' },
55
+ views: { type: 'number', min: 0, max: 10000 },
56
+ userId: { type: 'relation', resource: 'users' },
57
+ },
58
+ seed: 50,
59
+ },
60
+
61
+ comments: {
62
+ fields: {
63
+ body: { type: 'paragraph', required: true },
64
+ rating: { type: 'rating' },
65
+ userId: { type: 'relation', resource: 'users' },
66
+ postId: { type: 'relation', resource: 'posts' },
67
+ },
68
+ seed: 100,
69
+ },
70
+ },
71
+
72
+ // Chaos Engineering (Reality Mode) — Phase 2
73
+ // chaos: {
74
+ // enabled: true,
75
+ // jitter: { min: 100, max: 3000 },
76
+ // failureRate: 0.1,
77
+ // duplicateRate: 0.05,
78
+ // connectionDropRate: 0.02,
79
+ // },
80
+ };
81
+ `;
82
+
83
+ writeFileSync(configPath, template, 'utf-8');
84
+ logger.success(`Created ${configPath}`);
85
+ logger.info('Edit the file to define your resources, then run: phantomback start');
86
+ }
87
+
88
+ /**
89
+ * CLI command: phantomback start
90
+ */
91
+ export async function startCommand(options) {
92
+ const { parseConfig } = await import('../schema/parser.js');
93
+ const { DEFAULT_RESOURCES } = await import('../schema/defaults.js');
94
+ const { createServer } = await import('../server/createServer.js');
95
+
96
+ logger.banner();
97
+
98
+ let config;
99
+
100
+ if (options.zero) {
101
+ logger.info('Zero-config mode: generating demo backend...');
102
+ config = await parseConfig({
103
+ port: options.port || 3777,
104
+ prefix: options.prefix || '/api',
105
+ resources: DEFAULT_RESOURCES,
106
+ });
107
+ } else if (options.config) {
108
+ config = await parseConfig(options.config);
109
+ } else {
110
+ config = await parseConfig(); // auto-find config file
111
+ }
112
+
113
+ // CLI overrides
114
+ if (options.port) config.port = parseInt(options.port, 10);
115
+ if (options.prefix) config.prefix = options.prefix;
116
+
117
+ // Check if using defaults (no config found and no resources)
118
+ if (Object.keys(config.resources).length === 0) {
119
+ logger.warn('No config found and no resources defined. Using zero-config defaults.');
120
+ config.resources = DEFAULT_RESOURCES;
121
+ }
122
+
123
+ logger.table(config.resources);
124
+
125
+ const { stop } = await createServer(config);
126
+
127
+ logger.server(config.port);
128
+
129
+ // Graceful shutdown
130
+ const shutdown = async () => {
131
+ logger.info('Shutting down...');
132
+ await stop();
133
+ process.exit(0);
134
+ };
135
+
136
+ process.on('SIGINT', shutdown);
137
+ process.on('SIGTERM', shutdown);
138
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Detect and build relation metadata between resources.
3
+ * Used for auto-generating nested routes like GET /users/:id/posts
4
+ */
5
+ export function detectRelations(resources) {
6
+ const relations = {};
7
+
8
+ for (const [resourceName, schema] of Object.entries(resources)) {
9
+ const fields = schema.fields || {};
10
+
11
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
12
+ const def = typeof fieldDef === 'string' ? { type: fieldDef } : fieldDef;
13
+
14
+ if (def.type === 'relation' && def.resource) {
15
+ // This resource has a foreign key to def.resource
16
+ // e.g., posts.userId → users
17
+ if (!relations[def.resource]) {
18
+ relations[def.resource] = [];
19
+ }
20
+
21
+ relations[def.resource].push({
22
+ childResource: resourceName,
23
+ foreignKey: fieldName,
24
+ parentResource: def.resource,
25
+ });
26
+
27
+ // Also store on the child side
28
+ if (!relations[resourceName]) {
29
+ relations[resourceName] = [];
30
+ }
31
+
32
+ relations[resourceName].push({
33
+ parentResource: def.resource,
34
+ foreignKey: fieldName,
35
+ childResource: resourceName,
36
+ direction: 'belongsTo',
37
+ });
38
+ }
39
+ }
40
+ }
41
+
42
+ return relations;
43
+ }
44
+
45
+ /**
46
+ * Get child resources for a parent (for nested routes)
47
+ */
48
+ export function getChildren(resourceName, relations) {
49
+ if (!relations[resourceName]) return [];
50
+ return relations[resourceName].filter((r) => r.childResource !== resourceName || !r.direction);
51
+ }
52
+
53
+ /**
54
+ * Get parent resources for a child (for nested routes)
55
+ */
56
+ export function getParents(resourceName, relations) {
57
+ if (!relations[resourceName]) return [];
58
+ return relations[resourceName].filter((r) => r.direction === 'belongsTo');
59
+ }
@@ -0,0 +1,190 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import { generateId } from '../utils/helpers.js';
3
+
4
+ /**
5
+ * Map of schema field types → Faker generators
6
+ */
7
+ const FIELD_GENERATORS = {
8
+ // People
9
+ name: () => faker.person.fullName(),
10
+ firstName: () => faker.person.firstName(),
11
+ lastName: () => faker.person.lastName(),
12
+ username: () => faker.internet.username(),
13
+ email: () => faker.internet.email(),
14
+ avatar: () => faker.image.avatar(),
15
+ bio: () => faker.person.bio(),
16
+ jobTitle: () => faker.person.jobTitle(),
17
+ phone: () => faker.phone.number(),
18
+
19
+ // Text
20
+ word: () => faker.lorem.word(),
21
+ sentence: () => faker.lorem.sentence(),
22
+ paragraph: () => faker.lorem.paragraph(),
23
+ paragraphs: (opts) => faker.lorem.paragraphs(opts?.count || 3),
24
+ slug: () => faker.lorem.slug(),
25
+ title: () => faker.lorem.sentence({ min: 3, max: 8 }),
26
+ description: () => faker.lorem.sentences({ min: 2, max: 4 }),
27
+ text: () => faker.lorem.text(),
28
+
29
+ // Numbers
30
+ number: (opts) => faker.number.int({ min: opts?.min ?? 0, max: opts?.max ?? 1000 }),
31
+ float: (opts) =>
32
+ faker.number.float({
33
+ min: opts?.min ?? 0,
34
+ max: opts?.max ?? 1000,
35
+ fractionDigits: opts?.precision ?? 2,
36
+ }),
37
+ price: () => faker.commerce.price({ min: 1, max: 500, dec: 2 }),
38
+ rating: () => faker.number.float({ min: 1, max: 5, fractionDigits: 1 }),
39
+
40
+ // Boolean
41
+ boolean: () => faker.datatype.boolean(),
42
+
43
+ // Dates
44
+ date: () => faker.date.recent({ days: 365 }).toISOString(),
45
+ pastDate: () => faker.date.past().toISOString(),
46
+ futureDate: () => faker.date.future().toISOString(),
47
+ birthdate: () => faker.date.birthdate().toISOString().split('T')[0],
48
+
49
+ // Internet
50
+ url: () => faker.internet.url(),
51
+ image: () => faker.image.url(),
52
+ ip: () => faker.internet.ip(),
53
+ color: () => faker.color.human(),
54
+ hex: () => faker.color.rgb(),
55
+
56
+ // Address
57
+ address: () => faker.location.streetAddress(),
58
+ city: () => faker.location.city(),
59
+ country: () => faker.location.country(),
60
+ zipCode: () => faker.location.zipCode(),
61
+ latitude: () => faker.location.latitude(),
62
+ longitude: () => faker.location.longitude(),
63
+
64
+ // Commerce
65
+ product: () => faker.commerce.productName(),
66
+ company: () => faker.company.name(),
67
+ department: () => faker.commerce.department(),
68
+ category: () => faker.commerce.department(),
69
+
70
+ // IDs
71
+ uuid: () => faker.string.uuid(),
72
+ id: () => generateId(),
73
+
74
+ // Enum
75
+ enum: (opts) => {
76
+ if (!opts?.values || !opts.values.length) return null;
77
+ return faker.helpers.arrayElement(opts.values);
78
+ },
79
+
80
+ // Array
81
+ array: (opts) => {
82
+ const count = opts?.count || faker.number.int({ min: 1, max: 5 });
83
+ const itemType = opts?.items || 'word';
84
+ const generator = FIELD_GENERATORS[itemType];
85
+ if (!generator) return [];
86
+ return Array.from({ length: count }, () => generator(opts));
87
+ },
88
+
89
+ // Object (nested)
90
+ object: (opts) => {
91
+ if (!opts?.fields) return {};
92
+ return generateRecord(opts.fields);
93
+ },
94
+
95
+ // Relation (foreign key) — handled separately by seeder
96
+ relation: () => null,
97
+ };
98
+
99
+ /**
100
+ * Generate a single field value from the schema definition
101
+ */
102
+ export function generateFieldValue(fieldDef) {
103
+ // If fieldDef is a string, treat it as the type
104
+ const def = typeof fieldDef === 'string' ? { type: fieldDef } : fieldDef;
105
+ const generator = FIELD_GENERATORS[def.type];
106
+
107
+ if (!generator) {
108
+ // Fallback: if type matches a faker method path like "lorem.sentence"
109
+ return faker.lorem.word();
110
+ }
111
+
112
+ return generator(def);
113
+ }
114
+
115
+ /**
116
+ * Generate a single record from a fields schema
117
+ */
118
+ export function generateRecord(fields) {
119
+ const record = {};
120
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
121
+ record[fieldName] = generateFieldValue(fieldDef);
122
+ }
123
+ return record;
124
+ }
125
+
126
+ /**
127
+ * Seed a resource with realistic fake data
128
+ */
129
+ export function seedResource(resourceName, schema, store, _allResources = {}) {
130
+ const { fields, seed = 10 } = schema;
131
+ if (!fields || seed <= 0) return;
132
+
133
+ store.register(resourceName);
134
+
135
+ const records = [];
136
+ for (let i = 0; i < seed; i++) {
137
+ const record = generateRecord(fields);
138
+ records.push(record);
139
+ }
140
+
141
+ // Create all records first
142
+ const createdRecords = store.createMany(resourceName, records);
143
+
144
+ // Now resolve relations (foreign keys)
145
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
146
+ const def = typeof fieldDef === 'string' ? { type: fieldDef } : fieldDef;
147
+ if (def.type === 'relation' && def.resource) {
148
+ const relatedRecords = store.findAll(def.resource);
149
+ if (relatedRecords.length > 0) {
150
+ for (const record of createdRecords) {
151
+ const relatedRecord = faker.helpers.arrayElement(relatedRecords);
152
+ store.patch(resourceName, record.id, { [fieldName]: relatedRecord.id });
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Seed all resources respecting dependency order
161
+ */
162
+ export function seedAll(resources, store) {
163
+ // First pass: find resources without relations (they get seeded first)
164
+ const withRelations = [];
165
+ const withoutRelations = [];
166
+
167
+ for (const [name, schema] of Object.entries(resources)) {
168
+ const fields = schema.fields || {};
169
+ const hasRelation = Object.values(fields).some((f) => {
170
+ const def = typeof f === 'string' ? { type: f } : f;
171
+ return def.type === 'relation';
172
+ });
173
+
174
+ if (hasRelation) {
175
+ withRelations.push([name, schema]);
176
+ } else {
177
+ withoutRelations.push([name, schema]);
178
+ }
179
+ }
180
+
181
+ // Seed independent resources first
182
+ for (const [name, schema] of withoutRelations) {
183
+ seedResource(name, schema, store, resources);
184
+ }
185
+
186
+ // Then seed dependent resources
187
+ for (const [name, schema] of withRelations) {
188
+ seedResource(name, schema, store, resources);
189
+ }
190
+ }
@@ -0,0 +1,181 @@
1
+ import { generateId, timestamp, deepClone } from '../utils/helpers.js';
2
+
3
+ /**
4
+ * In-memory data store for all resources.
5
+ * Structure: Map<resourceName, Map<id, record>>
6
+ */
7
+ export class DataStore {
8
+ constructor() {
9
+ /** @type {Map<string, Map<string, object>>} */
10
+ this.collections = new Map();
11
+ }
12
+
13
+ /**
14
+ * Initialize a collection for a resource
15
+ */
16
+ register(resourceName) {
17
+ if (!this.collections.has(resourceName)) {
18
+ this.collections.set(resourceName, new Map());
19
+ }
20
+ return this;
21
+ }
22
+
23
+ /**
24
+ * Get all records from a resource
25
+ */
26
+ findAll(resourceName) {
27
+ const collection = this.collections.get(resourceName);
28
+ if (!collection) return [];
29
+ return Array.from(collection.values()).map(deepClone);
30
+ }
31
+
32
+ /**
33
+ * Get a single record by ID
34
+ */
35
+ findById(resourceName, id) {
36
+ const collection = this.collections.get(resourceName);
37
+ if (!collection) return null;
38
+ const record = collection.get(id);
39
+ return record ? deepClone(record) : null;
40
+ }
41
+
42
+ /**
43
+ * Find records matching a filter function
44
+ */
45
+ findWhere(resourceName, filterFn) {
46
+ return this.findAll(resourceName).filter(filterFn);
47
+ }
48
+
49
+ /**
50
+ * Create a new record
51
+ */
52
+ create(resourceName, data) {
53
+ const collection = this.collections.get(resourceName);
54
+ if (!collection) {
55
+ throw new Error(`Resource "${resourceName}" is not registered.`);
56
+ }
57
+
58
+ const now = timestamp();
59
+ const record = {
60
+ id: data.id || generateId(),
61
+ ...data,
62
+ createdAt: now,
63
+ updatedAt: now,
64
+ };
65
+
66
+ collection.set(record.id, record);
67
+ return deepClone(record);
68
+ }
69
+
70
+ /**
71
+ * Bulk create records
72
+ */
73
+ createMany(resourceName, items) {
74
+ return items.map((item) => this.create(resourceName, item));
75
+ }
76
+
77
+ /**
78
+ * Update a record (full replace, keeps id + timestamps)
79
+ */
80
+ update(resourceName, id, data) {
81
+ const collection = this.collections.get(resourceName);
82
+ if (!collection) return null;
83
+
84
+ const existing = collection.get(id);
85
+ if (!existing) return null;
86
+
87
+ const updated = {
88
+ ...data,
89
+ id,
90
+ createdAt: existing.createdAt,
91
+ updatedAt: timestamp(),
92
+ };
93
+
94
+ collection.set(id, updated);
95
+ return deepClone(updated);
96
+ }
97
+
98
+ /**
99
+ * Partial update a record (merge)
100
+ */
101
+ patch(resourceName, id, data) {
102
+ const collection = this.collections.get(resourceName);
103
+ if (!collection) return null;
104
+
105
+ const existing = collection.get(id);
106
+ if (!existing) return null;
107
+
108
+ const patched = {
109
+ ...existing,
110
+ ...data,
111
+ id, // prevent id override
112
+ createdAt: existing.createdAt,
113
+ updatedAt: timestamp(),
114
+ };
115
+
116
+ collection.set(id, patched);
117
+ return deepClone(patched);
118
+ }
119
+
120
+ /**
121
+ * Delete a record
122
+ */
123
+ delete(resourceName, id) {
124
+ const collection = this.collections.get(resourceName);
125
+ if (!collection) return false;
126
+ return collection.delete(id);
127
+ }
128
+
129
+ /**
130
+ * Count records in a resource
131
+ */
132
+ count(resourceName) {
133
+ const collection = this.collections.get(resourceName);
134
+ return collection ? collection.size : 0;
135
+ }
136
+
137
+ /**
138
+ * Clear all records from a resource
139
+ */
140
+ clear(resourceName) {
141
+ const collection = this.collections.get(resourceName);
142
+ if (collection) collection.clear();
143
+ return this;
144
+ }
145
+
146
+ /**
147
+ * Reset the entire store
148
+ */
149
+ reset() {
150
+ this.collections.clear();
151
+ return this;
152
+ }
153
+
154
+ /**
155
+ * Export the entire store as a plain object (for snapshots)
156
+ */
157
+ toJSON() {
158
+ const result = {};
159
+ for (const [name, collection] of this.collections) {
160
+ result[name] = Array.from(collection.values());
161
+ }
162
+ return result;
163
+ }
164
+
165
+ /**
166
+ * Import data from a plain object (restore snapshot)
167
+ */
168
+ fromJSON(data) {
169
+ for (const [name, records] of Object.entries(data)) {
170
+ this.register(name);
171
+ const collection = this.collections.get(name);
172
+ for (const record of records) {
173
+ collection.set(record.id, record);
174
+ }
175
+ }
176
+ return this;
177
+ }
178
+ }
179
+
180
+ // Singleton store instance
181
+ export const store = new DataStore();
@@ -0,0 +1,123 @@
1
+ import jwt from 'jsonwebtoken';
2
+ import { sendError, sendResponse, asyncHandler } from '../utils/helpers.js';
3
+
4
+ const DEFAULT_SECRET = 'phantomback-secret-key';
5
+ const DEFAULT_EXPIRES_IN = '24h';
6
+
7
+ /**
8
+ * Create auth routes (login, register)
9
+ */
10
+ export function createAuthRoutes(router, store, config) {
11
+ const secret = config.auth?.secret || DEFAULT_SECRET;
12
+ const expiresIn = config.auth?.expiresIn || DEFAULT_EXPIRES_IN;
13
+
14
+ // POST /api/auth/register
15
+ router.post(
16
+ `${config.prefix}/auth/register`,
17
+ asyncHandler(async (req, res) => {
18
+ const { email, password, ...rest } = req.body;
19
+
20
+ if (!email || !password) {
21
+ return sendError(res, 400, 'Email and password are required');
22
+ }
23
+
24
+ // Check if user already exists
25
+ const existing = store.findAll('users').find((u) => u.email === email);
26
+
27
+ if (existing) {
28
+ return sendError(res, 409, 'User with this email already exists');
29
+ }
30
+
31
+ // Create user (password is stored but this is a fake backend)
32
+ const user = store.create('users', {
33
+ email,
34
+ password, // In a real app, this would be hashed
35
+ ...rest,
36
+ });
37
+
38
+ const token = jwt.sign({ id: user.id, email: user.email }, secret, { expiresIn });
39
+
40
+ return sendResponse(res, 201, {
41
+ user: { ...user, password: undefined },
42
+ token,
43
+ });
44
+ }),
45
+ );
46
+
47
+ // POST /api/auth/login
48
+ router.post(
49
+ `${config.prefix}/auth/login`,
50
+ asyncHandler(async (req, res) => {
51
+ const { email, password } = req.body;
52
+
53
+ if (!email) {
54
+ return sendError(res, 400, 'Email is required');
55
+ }
56
+
57
+ // Find user by email
58
+ const user = store.findAll('users').find((u) => u.email === email);
59
+
60
+ if (!user) {
61
+ return sendError(res, 401, 'Invalid credentials');
62
+ }
63
+
64
+ // In phantom mode, any password works if user exists
65
+ // But if a password was set during registration, check it
66
+ if (user.password && password && user.password !== password) {
67
+ return sendError(res, 401, 'Invalid credentials');
68
+ }
69
+
70
+ const token = jwt.sign({ id: user.id, email: user.email }, secret, { expiresIn });
71
+
72
+ return sendResponse(res, 200, {
73
+ user: { ...user, password: undefined },
74
+ token,
75
+ });
76
+ }),
77
+ );
78
+
79
+ // GET /api/auth/me — get current user from token
80
+ router.get(
81
+ `${config.prefix}/auth/me`,
82
+ authMiddleware(secret),
83
+ asyncHandler(async (req, res) => {
84
+ const user = store.findById('users', req.user.id);
85
+ if (!user) {
86
+ return sendError(res, 404, 'User not found');
87
+ }
88
+ return sendResponse(res, 200, { ...user, password: undefined });
89
+ }),
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Auth middleware — verifies JWT token
95
+ */
96
+ export function authMiddleware(secret) {
97
+ return (req, res, next) => {
98
+ const authHeader = req.headers.authorization;
99
+
100
+ if (!authHeader) {
101
+ return sendError(res, 401, 'Authorization header is required. Use: Bearer <token>');
102
+ }
103
+
104
+ const parts = authHeader.split(' ');
105
+ if (parts.length !== 2 || parts[0] !== 'Bearer') {
106
+ return sendError(res, 401, 'Invalid authorization format. Use: Bearer <token>');
107
+ }
108
+
109
+ const token = parts[1];
110
+
111
+ try {
112
+ const secret_key = secret || DEFAULT_SECRET;
113
+ const decoded = jwt.verify(token, secret_key);
114
+ req.user = decoded;
115
+ next();
116
+ } catch (err) {
117
+ if (err.name === 'TokenExpiredError') {
118
+ return sendError(res, 401, 'Token has expired');
119
+ }
120
+ return sendError(res, 401, 'Invalid token');
121
+ }
122
+ };
123
+ }