@yunsoft/yuncms-api 0.1.5 → 0.1.7
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 +93 -50
- 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 +7 -2
- 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 +75 -52
- package/studio-dist/assets/index-B30LRjIx.js +9 -0
- package/studio-dist/index.html +1 -1
- package/studio-dist/assets/index-CT3jNSGp.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
|
@@ -12,25 +12,27 @@ import {
|
|
|
12
12
|
loadEnvFileIfPresent,
|
|
13
13
|
LocalStorageDriver,
|
|
14
14
|
MemoryCacheStore,
|
|
15
|
+
RedisCacheStore,
|
|
16
|
+
RedisClient,
|
|
17
|
+
RedisFixedWindowStore,
|
|
15
18
|
S3StorageDriver,
|
|
16
19
|
SchemaCache,
|
|
17
20
|
SmtpMailer,
|
|
18
21
|
} from '@yunsoft/yuncms-core';
|
|
19
22
|
import { createApp } from './app.js';
|
|
20
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';
|
|
21
26
|
import { loadExtensionRuntime } from './extensions/runtime.js';
|
|
27
|
+
import { createMcpRouter } from './mcp.js';
|
|
22
28
|
|
|
23
29
|
loadEnvFileIfPresent();
|
|
24
|
-
await assertMaintenanceStartupAllowed({
|
|
25
|
-
cwd: process.cwd(),
|
|
26
|
-
env: process.env,
|
|
27
|
-
});
|
|
30
|
+
await assertMaintenanceStartupAllowed({ cwd: process.cwd(), env: process.env });
|
|
28
31
|
const config = loadConfig();
|
|
32
|
+
const externalAuthConfig = loadExternalAuthConfig(process.env);
|
|
29
33
|
const logger = createJsonLogger({ level: config.logging.level });
|
|
30
34
|
const pool = createDatabasePool(config.database);
|
|
31
|
-
const storageDrivers = {
|
|
32
|
-
local: new LocalStorageDriver({ root: config.storage.localRoot }),
|
|
33
|
-
};
|
|
35
|
+
const storageDrivers = { local: new LocalStorageDriver({ root: config.storage.localRoot }) };
|
|
34
36
|
if (config.storage.s3.bucket) {
|
|
35
37
|
storageDrivers.s3 = new S3StorageDriver({
|
|
36
38
|
bucket: config.storage.s3.bucket,
|
|
@@ -42,46 +44,48 @@ if (config.storage.s3.bucket) {
|
|
|
42
44
|
});
|
|
43
45
|
}
|
|
44
46
|
const storage = createStorageRegistry(storageDrivers);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
|
|
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 })
|
|
50
63
|
: null;
|
|
51
64
|
|
|
52
|
-
const hasAnyMailConfig = Boolean(
|
|
53
|
-
config.mail.host || config.mail.from || config.mail.user || config.mail.password,
|
|
54
|
-
);
|
|
65
|
+
const hasAnyMailConfig = Boolean(config.mail.host || config.mail.from || config.mail.user || config.mail.password);
|
|
55
66
|
if (hasAnyMailConfig && (!config.mail.host || !config.mail.from)) {
|
|
56
67
|
throw new Error('SMTP_HOST and SMTP_FROM are both required when SMTP delivery is configured');
|
|
57
68
|
}
|
|
58
|
-
const mailer = config.mail.host
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
})
|
|
67
|
-
: 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;
|
|
68
77
|
|
|
69
78
|
let server = null;
|
|
79
|
+
let extensionRuntime = null;
|
|
70
80
|
let shuttingDown = false;
|
|
71
81
|
|
|
72
82
|
function registerInternalAudit({ emitter, services }) {
|
|
73
83
|
const AuditService = services.AuditService;
|
|
74
84
|
const systemAccountability = createSystemAccountability();
|
|
75
|
-
|
|
76
85
|
for (const event of INTERNAL_AUDIT_EVENTS) {
|
|
77
86
|
emitter.registerAction(event, async (payload, context) => {
|
|
78
87
|
try {
|
|
79
|
-
const audit = new AuditService({
|
|
80
|
-
accountability: systemAccountability,
|
|
81
|
-
database: pool,
|
|
82
|
-
logger,
|
|
83
|
-
requestId: context.requestId ?? null,
|
|
84
|
-
});
|
|
88
|
+
const audit = new AuditService({ accountability: systemAccountability, database: pool, logger, requestId: context.requestId ?? null });
|
|
85
89
|
await audit.record({
|
|
86
90
|
user: context.accountability?.user ?? null,
|
|
87
91
|
action: event,
|
|
@@ -91,27 +95,40 @@ function registerInternalAudit({ emitter, services }) {
|
|
|
91
95
|
payload,
|
|
92
96
|
});
|
|
93
97
|
} catch (error) {
|
|
94
|
-
logger.error('YunCMS audit write failed after committed mutation', {
|
|
95
|
-
event,
|
|
96
|
-
requestId: context.requestId ?? null,
|
|
97
|
-
code: error?.code,
|
|
98
|
-
error,
|
|
99
|
-
});
|
|
98
|
+
logger.error('YunCMS audit write failed after committed mutation', { event, requestId: context.requestId ?? null, code: error?.code, error });
|
|
100
99
|
}
|
|
101
|
-
});
|
|
100
|
+
}, { extensionId: 'core.audit', priority: 1000 });
|
|
102
101
|
}
|
|
103
102
|
}
|
|
104
103
|
|
|
105
104
|
async function start() {
|
|
106
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
|
+
}
|
|
107
116
|
|
|
108
117
|
const serviceRegistry = createCoreServiceRegistry();
|
|
109
118
|
const services = serviceRegistry.toObject();
|
|
110
119
|
const schemaCache = new SchemaCache();
|
|
111
|
-
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);
|
|
112
129
|
registerInternalAudit({ emitter, services });
|
|
113
130
|
|
|
114
|
-
|
|
131
|
+
extensionRuntime = await loadExtensionRuntime({
|
|
115
132
|
services,
|
|
116
133
|
database: pool,
|
|
117
134
|
schemaCache,
|
|
@@ -131,38 +148,46 @@ async function start() {
|
|
|
131
148
|
emitter,
|
|
132
149
|
storage,
|
|
133
150
|
mailer,
|
|
151
|
+
rateLimitStore,
|
|
152
|
+
redisClient,
|
|
153
|
+
externalAuthRegistry,
|
|
134
154
|
endpointExtensions: extensionRuntime.endpointExtensions,
|
|
155
|
+
mcpRouter,
|
|
135
156
|
});
|
|
136
157
|
|
|
137
158
|
await extensionRuntime.init('app.beforeStart');
|
|
138
159
|
server = await new Promise((resolve, reject) => {
|
|
139
160
|
const listeningServer = app.listen(config.server.port, config.server.host, () => {
|
|
140
|
-
logger.info('YunCMS API listening', {
|
|
141
|
-
host: config.server.host,
|
|
142
|
-
port: config.server.port,
|
|
143
|
-
});
|
|
161
|
+
logger.info('YunCMS API listening', { host: config.server.host, port: config.server.port });
|
|
144
162
|
resolve(listeningServer);
|
|
145
163
|
});
|
|
146
164
|
listeningServer.once('error', reject);
|
|
147
165
|
});
|
|
148
166
|
await extensionRuntime.init('app.afterStart');
|
|
167
|
+
extensionRuntime.startSchedules();
|
|
149
168
|
}
|
|
150
169
|
|
|
151
170
|
async function shutdown(signal) {
|
|
152
171
|
if (shuttingDown) return;
|
|
153
172
|
shuttingDown = true;
|
|
154
173
|
logger.info('YunCMS API shutting down', { signal });
|
|
155
|
-
|
|
156
174
|
const forceExit = setTimeout(() => {
|
|
157
175
|
logger.error('Graceful shutdown timed out', { signal });
|
|
158
176
|
process.exit(1);
|
|
159
177
|
}, 10_000);
|
|
160
178
|
forceExit.unref();
|
|
161
179
|
|
|
162
|
-
|
|
163
|
-
|
|
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 });
|
|
164
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(() => {});
|
|
165
189
|
await closeDatabasePool(pool);
|
|
190
|
+
await extensionRuntime?.init('app.afterStop').catch(() => {});
|
|
166
191
|
clearTimeout(forceExit);
|
|
167
192
|
logger.info('YunCMS API shutdown complete', { signal });
|
|
168
193
|
}
|
|
@@ -179,10 +204,8 @@ for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
|
179
204
|
}
|
|
180
205
|
|
|
181
206
|
start().catch(async (error) => {
|
|
182
|
-
logger.error('YunCMS API failed to start', {
|
|
183
|
-
|
|
184
|
-
error,
|
|
185
|
-
});
|
|
207
|
+
logger.error('YunCMS API failed to start', { code: error?.code, error });
|
|
208
|
+
await redisClient?.close().catch(() => {});
|
|
186
209
|
await closeDatabasePool(pool).catch(() => {});
|
|
187
210
|
process.exit(1);
|
|
188
211
|
});
|