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.
- package/LICENSE +21 -0
- package/README.md +459 -0
- package/bin/phantomback.js +39 -0
- package/package.json +63 -0
- package/src/cli/commands.js +138 -0
- package/src/data/relations.js +59 -0
- package/src/data/seeder.js +190 -0
- package/src/data/store.js +181 -0
- package/src/features/auth.js +123 -0
- package/src/features/delay.js +40 -0
- package/src/features/filters.js +84 -0
- package/src/features/pagination.js +38 -0
- package/src/features/search.js +18 -0
- package/src/features/sorting.js +47 -0
- package/src/index.js +67 -0
- package/src/schema/defaults.js +62 -0
- package/src/schema/parser.js +94 -0
- package/src/schema/validator.js +153 -0
- package/src/server/createServer.js +57 -0
- package/src/server/middleware.js +64 -0
- package/src/server/router.js +274 -0
- package/src/utils/helpers.js +114 -0
- package/src/utils/logger.js +58 -0
|
@@ -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
|
+
}
|