@yunsoft/yuncms-api 0.1.3 → 0.1.6
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/package.json +9 -3
- package/src/app.js +120 -30
- package/src/audit-events.js +18 -0
- package/src/authentication.js +2 -0
- package/src/error-response.js +1 -0
- package/src/extensions/runtime.js +44 -9
- package/src/extensions/scheduler.js +288 -0
- package/src/external-auth/config.js +204 -0
- package/src/external-auth/providers.js +440 -0
- package/src/mcp.js +364 -0
- package/src/pressure-limit.js +51 -0
- package/src/rate-limit.js +52 -22
- package/src/routes/auth.js +132 -55
- package/src/routes/schema.js +46 -9
- package/src/server.js +81 -52
- package/studio-dist/assets/index-B30LRjIx.js +9 -0
- package/studio-dist/index.html +1 -1
- package/studio-dist/assets/index-DPBu2LEh.js +0 -9
package/src/routes/auth.js
CHANGED
|
@@ -7,21 +7,16 @@ function service(req, name) {
|
|
|
7
7
|
const Service = req.context.services[name];
|
|
8
8
|
return new Service(serviceOptionsFromRequest(req));
|
|
9
9
|
}
|
|
10
|
-
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return service(req, 'ApiTokensService');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function usersService(req) {
|
|
24
|
-
return service(req, 'UsersService');
|
|
10
|
+
function authService(req) { return service(req, 'AuthService'); }
|
|
11
|
+
function authTokensService(req) { return service(req, 'AuthTokensService'); }
|
|
12
|
+
function apiTokensService(req) { return service(req, 'ApiTokensService'); }
|
|
13
|
+
function usersService(req) { return service(req, 'UsersService'); }
|
|
14
|
+
function externalAuthService(req, registry) {
|
|
15
|
+
const Service = req.context.services.ExternalAuthService;
|
|
16
|
+
return new Service({
|
|
17
|
+
...serviceOptionsFromRequest(req),
|
|
18
|
+
stateSecret: registry?.config?.stateSecret,
|
|
19
|
+
});
|
|
25
20
|
}
|
|
26
21
|
|
|
27
22
|
function requireSessionAuthentication(req) {
|
|
@@ -39,6 +34,13 @@ function requireMailer(mailer) {
|
|
|
39
34
|
throw error;
|
|
40
35
|
}
|
|
41
36
|
|
|
37
|
+
function requireExternalAuth(registry) {
|
|
38
|
+
if (registry?.config?.enabled) return registry;
|
|
39
|
+
const error = new Error('External authentication is not configured');
|
|
40
|
+
error.code = 'AUTH_PROVIDER_NOT_FOUND';
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
|
|
42
44
|
function actionUrl(config, action, token) {
|
|
43
45
|
return `${config.auth.publicUrl}/?auth_action=${encodeURIComponent(action)}&token=${encodeURIComponent(token)}`;
|
|
44
46
|
}
|
|
@@ -49,31 +51,126 @@ function noStore(req, res, next) {
|
|
|
49
51
|
next();
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
async function reportExternalFailure(serviceInstance, providerId, error) {
|
|
55
|
+
try {
|
|
56
|
+
await serviceInstance.loginFailed(providerId, error?.code ?? 'external_auth_failed');
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createAuthRouter({
|
|
61
|
+
mailer = null,
|
|
62
|
+
config = null,
|
|
63
|
+
logger = console,
|
|
64
|
+
rateLimitStore = null,
|
|
65
|
+
externalAuthRegistry = null,
|
|
66
|
+
} = {}) {
|
|
53
67
|
const router = express.Router();
|
|
68
|
+
const samlBodyParser = express.urlencoded({ extended: false, limit: '1mb', parameterLimit: 20 });
|
|
54
69
|
const limits = config?.auth?.rateLimit ?? {};
|
|
70
|
+
const sharedStore = limits.store === 'redis' ? rateLimitStore : null;
|
|
71
|
+
const common = { store: sharedStore, failureMode: limits.failureMode ?? 'best-effort', logger };
|
|
55
72
|
const loginLimit = createFixedWindowRateLimit({
|
|
73
|
+
...common,
|
|
74
|
+
scope: 'auth:login',
|
|
56
75
|
windowMs: limits.loginWindowMs ?? 60_000,
|
|
57
76
|
max: limits.loginMax ?? 10,
|
|
77
|
+
key: (req) => `${req.ip || req.socket?.remoteAddress || 'unknown'}:${String(req.body?.email ?? req.body?.username ?? '').trim().toLowerCase()}`,
|
|
58
78
|
});
|
|
59
79
|
const refreshLimit = createFixedWindowRateLimit({
|
|
80
|
+
...common,
|
|
81
|
+
scope: 'auth:refresh',
|
|
60
82
|
windowMs: limits.refreshWindowMs ?? 60_000,
|
|
61
83
|
max: limits.refreshMax ?? 30,
|
|
84
|
+
key: (req) => `${req.ip || req.socket?.remoteAddress || 'unknown'}:${String(req.body?.refresh_token ?? '').slice(0, 24)}`,
|
|
62
85
|
});
|
|
63
86
|
const actionLimit = createFixedWindowRateLimit({
|
|
87
|
+
...common,
|
|
88
|
+
scope: 'auth:action',
|
|
64
89
|
windowMs: limits.actionWindowMs ?? 15 * 60_000,
|
|
65
90
|
max: limits.actionMax ?? 5,
|
|
91
|
+
key: (req) => `${req.ip || req.socket?.remoteAddress || 'unknown'}:${String(req.body?.email ?? req.body?.user ?? req.params?.provider ?? '').trim().toLowerCase()}`,
|
|
66
92
|
});
|
|
67
93
|
|
|
68
94
|
router.use(noStore);
|
|
69
95
|
|
|
96
|
+
router.get('/providers', (req, res) => {
|
|
97
|
+
res.json({ data: externalAuthRegistry?.publicProviders?.() ?? [] });
|
|
98
|
+
});
|
|
99
|
+
|
|
70
100
|
router.post('/login', loginLimit, async (req, res) => {
|
|
71
|
-
const result = await authService(req).login({
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
101
|
+
const result = await authService(req).login({ email: req.body?.email, password: req.body?.password, ip: req.ip ?? null, userAgent: req.get('user-agent') ?? null });
|
|
102
|
+
res.json({ data: result });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
router.get('/login/:provider', actionLimit, async (req, res) => {
|
|
106
|
+
const registry = requireExternalAuth(externalAuthRegistry);
|
|
107
|
+
const auth = externalAuthService(req, registry);
|
|
108
|
+
try {
|
|
109
|
+
const result = await registry.begin(auth, req.params.provider, {
|
|
110
|
+
redirectTarget: req.query?.redirect ?? '/',
|
|
111
|
+
});
|
|
112
|
+
res.redirect(302, result.url.toString());
|
|
113
|
+
} catch (error) {
|
|
114
|
+
await reportExternalFailure(auth, req.params.provider, error);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
router.post('/login/:provider', loginLimit, async (req, res) => {
|
|
120
|
+
const registry = requireExternalAuth(externalAuthRegistry);
|
|
121
|
+
const auth = externalAuthService(req, registry);
|
|
122
|
+
try {
|
|
123
|
+
const result = await registry.loginLdap(auth, req.params.provider, {
|
|
124
|
+
username: req.body?.username,
|
|
125
|
+
password: req.body?.password,
|
|
126
|
+
ip: req.ip ?? null,
|
|
127
|
+
userAgent: req.get('user-agent') ?? null,
|
|
128
|
+
});
|
|
129
|
+
res.json({ data: result });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
await reportExternalFailure(auth, req.params.provider, error);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
router.get('/callback/:provider', actionLimit, async (req, res) => {
|
|
137
|
+
const registry = requireExternalAuth(externalAuthRegistry);
|
|
138
|
+
const auth = externalAuthService(req, registry);
|
|
139
|
+
try {
|
|
140
|
+
const completed = await registry.completeBrowser(auth, req.params.provider, {
|
|
141
|
+
query: req.query,
|
|
142
|
+
ip: req.ip ?? null,
|
|
143
|
+
userAgent: req.get('user-agent') ?? null,
|
|
144
|
+
});
|
|
145
|
+
const target = await registry.createBrowserHandoff(auth, completed.result, completed.redirectTarget);
|
|
146
|
+
res.redirect(303, target);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
await reportExternalFailure(auth, req.params.provider, error);
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
router.post('/callback/:provider', actionLimit, samlBodyParser, async (req, res) => {
|
|
154
|
+
const registry = requireExternalAuth(externalAuthRegistry);
|
|
155
|
+
const auth = externalAuthService(req, registry);
|
|
156
|
+
try {
|
|
157
|
+
const completed = await registry.completeBrowser(auth, req.params.provider, {
|
|
158
|
+
body: req.body,
|
|
159
|
+
ip: req.ip ?? null,
|
|
160
|
+
userAgent: req.get('user-agent') ?? null,
|
|
161
|
+
});
|
|
162
|
+
const target = await registry.createBrowserHandoff(auth, completed.result, completed.redirectTarget);
|
|
163
|
+
res.redirect(303, target);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
await reportExternalFailure(auth, req.params.provider, error);
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
router.post('/exchange', actionLimit, async (req, res) => {
|
|
171
|
+
const registry = requireExternalAuth(externalAuthRegistry);
|
|
172
|
+
const auth = externalAuthService(req, registry);
|
|
173
|
+
const result = await registry.exchangeBrowserHandoff(auth, req.body?.auth_code);
|
|
77
174
|
res.json({ data: result });
|
|
78
175
|
});
|
|
79
176
|
|
|
@@ -81,23 +178,19 @@ export function createAuthRouter({ mailer = null, config = null, logger = consol
|
|
|
81
178
|
const result = await authService(req).refresh(req.body?.refresh_token);
|
|
82
179
|
res.json({ data: result });
|
|
83
180
|
});
|
|
84
|
-
|
|
85
181
|
router.post('/logout', async (req, res) => {
|
|
86
182
|
requireSessionAuthentication(req);
|
|
87
183
|
await authService(req).logout(req.authToken);
|
|
88
184
|
res.status(204).end();
|
|
89
185
|
});
|
|
90
|
-
|
|
91
186
|
router.post('/logout-all', async (req, res) => {
|
|
92
187
|
requireSessionAuthentication(req);
|
|
93
188
|
await authService(req).logoutAll();
|
|
94
189
|
res.status(204).end();
|
|
95
190
|
});
|
|
96
|
-
|
|
97
191
|
router.post('/password-reset/request', actionLimit, async (req, res) => {
|
|
98
192
|
const transport = requireMailer(mailer);
|
|
99
193
|
const result = await authTokensService(req).requestPasswordReset(req.body?.email);
|
|
100
|
-
|
|
101
194
|
if (result) {
|
|
102
195
|
try {
|
|
103
196
|
const url = actionUrl(config, 'reset', result.token);
|
|
@@ -105,24 +198,17 @@ export function createAuthRouter({ mailer = null, config = null, logger = consol
|
|
|
105
198
|
to: String(req.body.email).trim(),
|
|
106
199
|
subject: 'Reset your YunCMS password',
|
|
107
200
|
text: `A password reset was requested for your YunCMS account.\n\nOpen this link to choose a new password:\n${url}\n\nIf you did not request this, you can ignore this message.`,
|
|
108
|
-
});
|
|
201
|
+
}, { accountability: req.accountability, requestId: req.id });
|
|
109
202
|
} catch (error) {
|
|
110
|
-
logger.error?.('YunCMS password reset mail delivery failed', {
|
|
111
|
-
requestId: req.id,
|
|
112
|
-
code: error?.code,
|
|
113
|
-
message: error?.message,
|
|
114
|
-
});
|
|
203
|
+
logger.error?.('YunCMS password reset mail delivery failed', { requestId: req.id, code: error?.code, message: error?.message });
|
|
115
204
|
}
|
|
116
205
|
}
|
|
117
|
-
|
|
118
206
|
res.status(202).json({ data: { accepted: true } });
|
|
119
207
|
});
|
|
120
|
-
|
|
121
208
|
router.post('/password-reset/confirm', actionLimit, async (req, res) => {
|
|
122
209
|
await authTokensService(req).resetPassword(req.body?.token, req.body?.password);
|
|
123
210
|
res.status(204).end();
|
|
124
211
|
});
|
|
125
|
-
|
|
126
212
|
router.post('/email-verification/request', actionLimit, async (req, res) => {
|
|
127
213
|
const transport = requireMailer(mailer);
|
|
128
214
|
if (!req.accountability?.user) {
|
|
@@ -130,7 +216,6 @@ export function createAuthRouter({ mailer = null, config = null, logger = consol
|
|
|
130
216
|
error.code = 'UNAUTHORIZED';
|
|
131
217
|
throw error;
|
|
132
218
|
}
|
|
133
|
-
|
|
134
219
|
const userId = req.body?.user ?? req.accountability.user;
|
|
135
220
|
const user = await usersService(req).readOne(userId);
|
|
136
221
|
if (!user) {
|
|
@@ -140,29 +225,15 @@ export function createAuthRouter({ mailer = null, config = null, logger = consol
|
|
|
140
225
|
}
|
|
141
226
|
const result = await authTokensService(req).createEmailVerification(userId);
|
|
142
227
|
const url = actionUrl(config, 'verify', result.token);
|
|
143
|
-
await transport.send({
|
|
144
|
-
to: user.email,
|
|
145
|
-
subject: 'Verify your YunCMS email',
|
|
146
|
-
text: `Verify your YunCMS email address by opening this link:\n${url}\n\nIf you did not request this, you can ignore this message.`,
|
|
147
|
-
});
|
|
228
|
+
await transport.send({ to: user.email, subject: 'Verify your YunCMS email', text: `Verify your YunCMS email address by opening this link:\n${url}\n\nIf you did not request this, you can ignore this message.` }, { accountability: req.accountability, requestId: req.id });
|
|
148
229
|
res.status(202).json({ data: { accepted: true } });
|
|
149
230
|
});
|
|
150
|
-
|
|
151
231
|
router.post('/email-verification/confirm', actionLimit, async (req, res) => {
|
|
152
232
|
await authTokensService(req).verifyEmail(req.body?.token);
|
|
153
233
|
res.status(204).end();
|
|
154
234
|
});
|
|
155
|
-
|
|
156
|
-
router.
|
|
157
|
-
const data = await apiTokensService(req).readMany();
|
|
158
|
-
res.json({ data });
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
router.post('/tokens', async (req, res) => {
|
|
162
|
-
const data = await apiTokensService(req).createOne(req.body ?? {});
|
|
163
|
-
res.status(201).json({ data });
|
|
164
|
-
});
|
|
165
|
-
|
|
235
|
+
router.get('/tokens', async (req, res) => { res.json({ data: await apiTokensService(req).readMany() }); });
|
|
236
|
+
router.post('/tokens', async (req, res) => { res.status(201).json({ data: await apiTokensService(req).createOne(req.body ?? {}) }); });
|
|
166
237
|
router.delete('/tokens/:id', async (req, res) => {
|
|
167
238
|
const deleted = await apiTokensService(req).deleteOne(req.params.id);
|
|
168
239
|
if (!deleted) {
|
|
@@ -172,8 +243,14 @@ export function createAuthRouter({ mailer = null, config = null, logger = consol
|
|
|
172
243
|
}
|
|
173
244
|
res.status(204).end();
|
|
174
245
|
});
|
|
175
|
-
|
|
176
246
|
return router;
|
|
177
247
|
}
|
|
178
248
|
|
|
179
|
-
export {
|
|
249
|
+
export {
|
|
250
|
+
actionUrl,
|
|
251
|
+
noStore,
|
|
252
|
+
reportExternalFailure,
|
|
253
|
+
requireExternalAuth,
|
|
254
|
+
requireMailer,
|
|
255
|
+
requireSessionAuthentication,
|
|
256
|
+
};
|
package/src/routes/schema.js
CHANGED
|
@@ -17,7 +17,39 @@ function destructiveRequested(req) {
|
|
|
17
17
|
return String(req.query?.destructive ?? '').toLowerCase() === 'true';
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function stableSchemaEvent(action) {
|
|
21
|
+
if (action.startsWith('schema.relation.')) {
|
|
22
|
+
return action.endsWith('.delete') ? 'schema.relation.delete' : 'schema.relation.create';
|
|
23
|
+
}
|
|
24
|
+
if (action === 'schema.field.alter') return 'schema.field.update';
|
|
25
|
+
return action;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function emitSchemaLifecycle(req, { action, collection = null, itemKey = null, payload = null }) {
|
|
29
|
+
const emitter = req.context.emitter;
|
|
30
|
+
if (!emitter) return;
|
|
31
|
+
const event = stableSchemaEvent(action);
|
|
32
|
+
const context = {
|
|
33
|
+
accountability: req.accountability,
|
|
34
|
+
requestId: req.id,
|
|
35
|
+
collection,
|
|
36
|
+
operation: event,
|
|
37
|
+
};
|
|
38
|
+
await emitter.action(event, {
|
|
39
|
+
key: itemKey,
|
|
40
|
+
collection,
|
|
41
|
+
...payload,
|
|
42
|
+
}, context);
|
|
43
|
+
await emitter.action('schema.changed', {
|
|
44
|
+
event,
|
|
45
|
+
key: itemKey,
|
|
46
|
+
collection,
|
|
47
|
+
schemaVersion: payload?.after?.schemaVersion ?? payload?.result?.schemaVersion ?? null,
|
|
48
|
+
}, context);
|
|
49
|
+
}
|
|
50
|
+
|
|
20
51
|
async function auditSchema(req, { action, collection = null, itemKey = null, payload = null }) {
|
|
52
|
+
await emitSchemaLifecycle(req, { action, collection, itemKey, payload });
|
|
21
53
|
try {
|
|
22
54
|
await service(req, 'AuditService').record({
|
|
23
55
|
action,
|
|
@@ -88,7 +120,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
|
|
|
88
120
|
router.delete('/collections/:collection', async (req, res) => {
|
|
89
121
|
const collections = service(req, 'CollectionsService');
|
|
90
122
|
const before = await collections.readOne(req.params.collection);
|
|
91
|
-
await collections.deleteOne(req.params.collection, {
|
|
123
|
+
const result = await collections.deleteOne(req.params.collection, {
|
|
92
124
|
destructive: destructiveRequested(req),
|
|
93
125
|
});
|
|
94
126
|
clearSchemaCache();
|
|
@@ -96,7 +128,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
|
|
|
96
128
|
action: 'schema.collection.delete',
|
|
97
129
|
collection: req.params.collection,
|
|
98
130
|
itemKey: req.params.collection,
|
|
99
|
-
payload: { before },
|
|
131
|
+
payload: { before, result },
|
|
100
132
|
});
|
|
101
133
|
res.status(204).end();
|
|
102
134
|
});
|
|
@@ -172,7 +204,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
|
|
|
172
204
|
router.delete('/collections/:collection/fields/:field', async (req, res) => {
|
|
173
205
|
const fields = service(req, 'FieldsService');
|
|
174
206
|
const before = await fields.readOne(req.params.collection, req.params.field);
|
|
175
|
-
await fields.deleteOne(
|
|
207
|
+
const result = await fields.deleteOne(
|
|
176
208
|
req.params.collection,
|
|
177
209
|
req.params.field,
|
|
178
210
|
{ destructive: destructiveRequested(req) },
|
|
@@ -182,7 +214,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
|
|
|
182
214
|
action: 'schema.field.delete',
|
|
183
215
|
collection: req.params.collection,
|
|
184
216
|
itemKey: req.params.field,
|
|
185
|
-
payload: { before },
|
|
217
|
+
payload: { before, result },
|
|
186
218
|
});
|
|
187
219
|
res.status(204).end();
|
|
188
220
|
});
|
|
@@ -225,7 +257,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
|
|
|
225
257
|
router.delete('/relations/m2o/:manyCollection/:manyField', async (req, res) => {
|
|
226
258
|
const relations = service(req, 'RelationsService');
|
|
227
259
|
const before = await relations.readOne(req.params.manyCollection, req.params.manyField);
|
|
228
|
-
await relations.deleteM2O(
|
|
260
|
+
const result = await relations.deleteM2O(
|
|
229
261
|
req.params.manyCollection,
|
|
230
262
|
req.params.manyField,
|
|
231
263
|
);
|
|
@@ -234,7 +266,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
|
|
|
234
266
|
action: 'schema.relation.delete',
|
|
235
267
|
collection: req.params.manyCollection,
|
|
236
268
|
itemKey: req.params.manyField,
|
|
237
|
-
payload: { before },
|
|
269
|
+
payload: { before, result },
|
|
238
270
|
});
|
|
239
271
|
res.status(204).end();
|
|
240
272
|
});
|
|
@@ -258,7 +290,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
|
|
|
258
290
|
router.delete('/relations/o2o/:manyCollection/:manyField', async (req, res) => {
|
|
259
291
|
const relations = service(req, 'RelationsService');
|
|
260
292
|
const before = await relations.readOne(req.params.manyCollection, req.params.manyField);
|
|
261
|
-
await deleteO2ORelation({
|
|
293
|
+
const result = await deleteO2ORelation({
|
|
262
294
|
database: req.context.database,
|
|
263
295
|
accountability: req.accountability,
|
|
264
296
|
manyCollection: req.params.manyCollection,
|
|
@@ -269,7 +301,7 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
|
|
|
269
301
|
action: 'schema.relation.o2o.delete',
|
|
270
302
|
collection: req.params.manyCollection,
|
|
271
303
|
itemKey: req.params.manyField,
|
|
272
|
-
payload: { before },
|
|
304
|
+
payload: { before, result },
|
|
273
305
|
});
|
|
274
306
|
res.status(204).end();
|
|
275
307
|
});
|
|
@@ -310,4 +342,9 @@ export function createSchemaRouter({ schemaCache = null } = {}) {
|
|
|
310
342
|
return router;
|
|
311
343
|
}
|
|
312
344
|
|
|
313
|
-
export {
|
|
345
|
+
export {
|
|
346
|
+
auditSchema,
|
|
347
|
+
destructiveRequested,
|
|
348
|
+
emitSchemaLifecycle,
|
|
349
|
+
stableSchemaEvent,
|
|
350
|
+
};
|
package/src/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assertDatabaseCompatible,
|
|
3
|
+
assertMaintenanceStartupAllowed,
|
|
3
4
|
closeDatabasePool,
|
|
4
5
|
createCoreServiceRegistry,
|
|
5
6
|
createDatabasePool,
|
|
@@ -10,20 +11,28 @@ import {
|
|
|
10
11
|
loadConfig,
|
|
11
12
|
loadEnvFileIfPresent,
|
|
12
13
|
LocalStorageDriver,
|
|
14
|
+
MemoryCacheStore,
|
|
15
|
+
RedisCacheStore,
|
|
16
|
+
RedisClient,
|
|
17
|
+
RedisFixedWindowStore,
|
|
13
18
|
S3StorageDriver,
|
|
14
19
|
SchemaCache,
|
|
15
20
|
SmtpMailer,
|
|
16
21
|
} from '@yunsoft/yuncms-core';
|
|
17
22
|
import { createApp } from './app.js';
|
|
23
|
+
import { INTERNAL_AUDIT_EVENTS } from './audit-events.js';
|
|
24
|
+
import { loadExternalAuthConfig } from './external-auth/config.js';
|
|
25
|
+
import { ExternalAuthProviderRegistry } from './external-auth/providers.js';
|
|
18
26
|
import { loadExtensionRuntime } from './extensions/runtime.js';
|
|
27
|
+
import { createMcpRouter } from './mcp.js';
|
|
19
28
|
|
|
20
29
|
loadEnvFileIfPresent();
|
|
30
|
+
await assertMaintenanceStartupAllowed({ cwd: process.cwd(), env: process.env });
|
|
21
31
|
const config = loadConfig();
|
|
32
|
+
const externalAuthConfig = loadExternalAuthConfig(process.env);
|
|
22
33
|
const logger = createJsonLogger({ level: config.logging.level });
|
|
23
34
|
const pool = createDatabasePool(config.database);
|
|
24
|
-
const storageDrivers = {
|
|
25
|
-
local: new LocalStorageDriver({ root: config.storage.localRoot }),
|
|
26
|
-
};
|
|
35
|
+
const storageDrivers = { local: new LocalStorageDriver({ root: config.storage.localRoot }) };
|
|
27
36
|
if (config.storage.s3.bucket) {
|
|
28
37
|
storageDrivers.s3 = new S3StorageDriver({
|
|
29
38
|
bucket: config.storage.s3.bucket,
|
|
@@ -36,47 +45,47 @@ if (config.storage.s3.bucket) {
|
|
|
36
45
|
}
|
|
37
46
|
const storage = createStorageRegistry(storageDrivers);
|
|
38
47
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
const redisNeeded = config.cache.store === 'redis'
|
|
49
|
+
|| config.server.rateLimit.store === 'redis'
|
|
50
|
+
|| config.auth.rateLimit.store === 'redis';
|
|
51
|
+
const redisClient = redisNeeded ? new RedisClient({
|
|
52
|
+
url: config.redis.url,
|
|
53
|
+
connectTimeoutMs: config.redis.connectTimeoutMs,
|
|
54
|
+
commandTimeoutMs: config.redis.commandTimeoutMs,
|
|
55
|
+
logger,
|
|
56
|
+
}) : null;
|
|
57
|
+
const permissionCache = !config.cache.enabled ? null
|
|
58
|
+
: config.cache.store === 'redis'
|
|
59
|
+
? new RedisCacheStore({ client: redisClient, prefix: config.redis.prefix, namespace: 'permission', ttlMs: config.cache.ttlMs, logger })
|
|
60
|
+
: new MemoryCacheStore({ ttlMs: config.cache.ttlMs, maxEntries: config.cache.maxEntries });
|
|
61
|
+
const rateLimitStore = redisClient
|
|
62
|
+
? new RedisFixedWindowStore({ client: redisClient, prefix: config.redis.prefix, logger })
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
const hasAnyMailConfig = Boolean(config.mail.host || config.mail.from || config.mail.user || config.mail.password);
|
|
42
66
|
if (hasAnyMailConfig && (!config.mail.host || !config.mail.from)) {
|
|
43
67
|
throw new Error('SMTP_HOST and SMTP_FROM are both required when SMTP delivery is configured');
|
|
44
68
|
}
|
|
45
|
-
const mailer = config.mail.host
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
})
|
|
54
|
-
: null;
|
|
69
|
+
const mailer = config.mail.host ? new SmtpMailer({
|
|
70
|
+
host: config.mail.host,
|
|
71
|
+
port: config.mail.port,
|
|
72
|
+
secure: config.mail.secure,
|
|
73
|
+
user: config.mail.user,
|
|
74
|
+
password: config.mail.password,
|
|
75
|
+
from: config.mail.from,
|
|
76
|
+
}) : null;
|
|
55
77
|
|
|
56
78
|
let server = null;
|
|
79
|
+
let extensionRuntime = null;
|
|
57
80
|
let shuttingDown = false;
|
|
58
81
|
|
|
59
82
|
function registerInternalAudit({ emitter, services }) {
|
|
60
83
|
const AuditService = services.AuditService;
|
|
61
84
|
const systemAccountability = createSystemAccountability();
|
|
62
|
-
const
|
|
63
|
-
'items.create',
|
|
64
|
-
'items.update',
|
|
65
|
-
'items.delete',
|
|
66
|
-
'files.create',
|
|
67
|
-
'files.update',
|
|
68
|
-
'files.delete',
|
|
69
|
-
];
|
|
70
|
-
|
|
71
|
-
for (const event of events) {
|
|
85
|
+
for (const event of INTERNAL_AUDIT_EVENTS) {
|
|
72
86
|
emitter.registerAction(event, async (payload, context) => {
|
|
73
87
|
try {
|
|
74
|
-
const audit = new AuditService({
|
|
75
|
-
accountability: systemAccountability,
|
|
76
|
-
database: pool,
|
|
77
|
-
logger,
|
|
78
|
-
requestId: context.requestId ?? null,
|
|
79
|
-
});
|
|
88
|
+
const audit = new AuditService({ accountability: systemAccountability, database: pool, logger, requestId: context.requestId ?? null });
|
|
80
89
|
await audit.record({
|
|
81
90
|
user: context.accountability?.user ?? null,
|
|
82
91
|
action: event,
|
|
@@ -86,27 +95,40 @@ function registerInternalAudit({ emitter, services }) {
|
|
|
86
95
|
payload,
|
|
87
96
|
});
|
|
88
97
|
} catch (error) {
|
|
89
|
-
logger.error('YunCMS audit write failed after committed mutation', {
|
|
90
|
-
event,
|
|
91
|
-
requestId: context.requestId ?? null,
|
|
92
|
-
code: error?.code,
|
|
93
|
-
error,
|
|
94
|
-
});
|
|
98
|
+
logger.error('YunCMS audit write failed after committed mutation', { event, requestId: context.requestId ?? null, code: error?.code, error });
|
|
95
99
|
}
|
|
96
|
-
});
|
|
100
|
+
}, { extensionId: 'core.audit', priority: 1000 });
|
|
97
101
|
}
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
async function start() {
|
|
101
105
|
await assertDatabaseCompatible(pool);
|
|
106
|
+
if (redisClient) {
|
|
107
|
+
try {
|
|
108
|
+
await redisClient.connect();
|
|
109
|
+
if (!await redisClient.ping()) throw new Error('Unexpected Redis ping result');
|
|
110
|
+
logger.info('YunCMS shared Redis connected', { prefix: config.redis.prefix });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (config.redis.required) throw error;
|
|
113
|
+
logger.warn('YunCMS Redis unavailable at startup; safe fallbacks remain active', { code: error?.code ?? null });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
102
116
|
|
|
103
117
|
const serviceRegistry = createCoreServiceRegistry();
|
|
104
118
|
const services = serviceRegistry.toObject();
|
|
105
119
|
const schemaCache = new SchemaCache();
|
|
106
|
-
const emitter = new HookEmitter();
|
|
120
|
+
const emitter = new HookEmitter({ logger });
|
|
121
|
+
const externalAuthRegistry = new ExternalAuthProviderRegistry({
|
|
122
|
+
config: externalAuthConfig,
|
|
123
|
+
publicUrl: config.auth.publicUrl,
|
|
124
|
+
database: pool,
|
|
125
|
+
logger,
|
|
126
|
+
});
|
|
127
|
+
const mcpRouter = createMcpRouter({ config, logger });
|
|
128
|
+
mailer?.setEmitter(emitter);
|
|
107
129
|
registerInternalAudit({ emitter, services });
|
|
108
130
|
|
|
109
|
-
|
|
131
|
+
extensionRuntime = await loadExtensionRuntime({
|
|
110
132
|
services,
|
|
111
133
|
database: pool,
|
|
112
134
|
schemaCache,
|
|
@@ -122,41 +144,50 @@ async function start() {
|
|
|
122
144
|
logger,
|
|
123
145
|
serviceRegistry,
|
|
124
146
|
schemaCache,
|
|
147
|
+
permissionCache,
|
|
125
148
|
emitter,
|
|
126
149
|
storage,
|
|
127
150
|
mailer,
|
|
151
|
+
rateLimitStore,
|
|
152
|
+
redisClient,
|
|
153
|
+
externalAuthRegistry,
|
|
128
154
|
endpointExtensions: extensionRuntime.endpointExtensions,
|
|
155
|
+
mcpRouter,
|
|
129
156
|
});
|
|
130
157
|
|
|
131
158
|
await extensionRuntime.init('app.beforeStart');
|
|
132
159
|
server = await new Promise((resolve, reject) => {
|
|
133
160
|
const listeningServer = app.listen(config.server.port, config.server.host, () => {
|
|
134
|
-
logger.info('YunCMS API listening', {
|
|
135
|
-
host: config.server.host,
|
|
136
|
-
port: config.server.port,
|
|
137
|
-
});
|
|
161
|
+
logger.info('YunCMS API listening', { host: config.server.host, port: config.server.port });
|
|
138
162
|
resolve(listeningServer);
|
|
139
163
|
});
|
|
140
164
|
listeningServer.once('error', reject);
|
|
141
165
|
});
|
|
142
166
|
await extensionRuntime.init('app.afterStart');
|
|
167
|
+
extensionRuntime.startSchedules();
|
|
143
168
|
}
|
|
144
169
|
|
|
145
170
|
async function shutdown(signal) {
|
|
146
171
|
if (shuttingDown) return;
|
|
147
172
|
shuttingDown = true;
|
|
148
173
|
logger.info('YunCMS API shutting down', { signal });
|
|
149
|
-
|
|
150
174
|
const forceExit = setTimeout(() => {
|
|
151
175
|
logger.error('Graceful shutdown timed out', { signal });
|
|
152
176
|
process.exit(1);
|
|
153
177
|
}, 10_000);
|
|
154
178
|
forceExit.unref();
|
|
155
179
|
|
|
156
|
-
|
|
157
|
-
|
|
180
|
+
const schedulesStopped = await extensionRuntime?.stopSchedules({ timeoutMs: 5_000 }).catch(() => false);
|
|
181
|
+
if (schedulesStopped === false) {
|
|
182
|
+
logger.warn('YunCMS extension jobs exceeded graceful shutdown budget', { signal });
|
|
158
183
|
}
|
|
184
|
+
await extensionRuntime?.init('app.beforeStop').catch((error) => {
|
|
185
|
+
logger.error('YunCMS extension beforeStop hook failed', { code: error?.code ?? null });
|
|
186
|
+
});
|
|
187
|
+
if (server) await new Promise((resolve) => server.close(resolve));
|
|
188
|
+
await redisClient?.close().catch(() => {});
|
|
159
189
|
await closeDatabasePool(pool);
|
|
190
|
+
await extensionRuntime?.init('app.afterStop').catch(() => {});
|
|
160
191
|
clearTimeout(forceExit);
|
|
161
192
|
logger.info('YunCMS API shutdown complete', { signal });
|
|
162
193
|
}
|
|
@@ -173,10 +204,8 @@ for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
|
173
204
|
}
|
|
174
205
|
|
|
175
206
|
start().catch(async (error) => {
|
|
176
|
-
logger.error('YunCMS API failed to start', {
|
|
177
|
-
|
|
178
|
-
error,
|
|
179
|
-
});
|
|
207
|
+
logger.error('YunCMS API failed to start', { code: error?.code, error });
|
|
208
|
+
await redisClient?.close().catch(() => {});
|
|
180
209
|
await closeDatabasePool(pool).catch(() => {});
|
|
181
210
|
process.exit(1);
|
|
182
211
|
});
|