@yunsoft/yuncms-core 0.1.5 → 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 +1 -1
- package/src/auth/external-state.js +76 -0
- package/src/bootstrap.js +2 -0
- package/src/config.js +102 -57
- package/src/hooks.js +117 -32
- package/src/index.js +23 -7
- package/src/mail/smtp-mailer.js +58 -14
- package/src/migrations/0013-external-auth-foundation.js +35 -0
- package/src/query.js +131 -71
- package/src/redis.js +300 -0
- package/src/relation-expansion.js +511 -223
- package/src/services/auth-service.js +40 -2
- package/src/services/core-services.js +2 -0
- package/src/services/external-auth-service.js +323 -0
- package/src/services/items-service.js +80 -93
package/package.json
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
randomBytes,
|
|
6
|
+
timingSafeEqual,
|
|
7
|
+
} from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
function authStateError(code, message) {
|
|
10
|
+
const error = new Error(message);
|
|
11
|
+
error.code = code;
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stateKey(secret) {
|
|
16
|
+
if (typeof secret !== 'string' || secret.length < 32) {
|
|
17
|
+
throw authStateError('INVALID_AUTH_PROVIDER_CONFIG', 'AUTH_STATE_SECRET must contain at least 32 characters');
|
|
18
|
+
}
|
|
19
|
+
return createHash('sha256').update(secret, 'utf8').digest();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createExternalAuthState(bytes = 32) {
|
|
23
|
+
if (!Number.isInteger(bytes) || bytes < 24 || bytes > 64) throw new Error('External auth state bytes must be between 24 and 64');
|
|
24
|
+
return randomBytes(bytes).toString('base64url');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hashExternalAuthState(value) {
|
|
28
|
+
if (typeof value !== 'string' || value.length < 16 || value.length > 512) {
|
|
29
|
+
throw authStateError('INVALID_AUTH_TRANSACTION', 'External auth state is invalid');
|
|
30
|
+
}
|
|
31
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function constantTimeStateEqual(left, right) {
|
|
35
|
+
const a = Buffer.from(hashExternalAuthState(left), 'hex');
|
|
36
|
+
const b = Buffer.from(hashExternalAuthState(right), 'hex');
|
|
37
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function encryptExternalAuthSecret(secret, value) {
|
|
41
|
+
if (value == null) return null;
|
|
42
|
+
const key = stateKey(secret);
|
|
43
|
+
const iv = randomBytes(12);
|
|
44
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
45
|
+
const plaintext = Buffer.from(JSON.stringify(value), 'utf8');
|
|
46
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
47
|
+
const tag = cipher.getAuthTag();
|
|
48
|
+
return `v1.${iv.toString('base64url')}.${tag.toString('base64url')}.${ciphertext.toString('base64url')}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function decryptExternalAuthSecret(secret, value) {
|
|
52
|
+
if (value == null) return null;
|
|
53
|
+
const parts = String(value).split('.');
|
|
54
|
+
if (parts.length !== 4 || parts[0] !== 'v1') throw authStateError('INVALID_AUTH_TRANSACTION', 'External auth transaction secret is invalid');
|
|
55
|
+
try {
|
|
56
|
+
const key = stateKey(secret);
|
|
57
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(parts[1], 'base64url'));
|
|
58
|
+
decipher.setAuthTag(Buffer.from(parts[2], 'base64url'));
|
|
59
|
+
const plaintext = Buffer.concat([
|
|
60
|
+
decipher.update(Buffer.from(parts[3], 'base64url')),
|
|
61
|
+
decipher.final(),
|
|
62
|
+
]).toString('utf8');
|
|
63
|
+
return JSON.parse(plaintext);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw authStateError('INVALID_AUTH_TRANSACTION', `External auth transaction secret could not be verified: ${error?.code ?? 'decrypt_failed'}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function assertLocalRedirectTarget(value, fallback = '/') {
|
|
70
|
+
if (value == null || value === '') return fallback;
|
|
71
|
+
const target = String(value);
|
|
72
|
+
if (!target.startsWith('/') || target.startsWith('//') || target.includes('\\') || /[\r\n\0]/.test(target) || target.length > 512) {
|
|
73
|
+
throw authStateError('INVALID_REDIRECT_TARGET', 'External auth redirect target must be a local path');
|
|
74
|
+
}
|
|
75
|
+
return target;
|
|
76
|
+
}
|
package/src/bootstrap.js
CHANGED
|
@@ -12,6 +12,7 @@ import { schemaDisplayNamesMigration } from './migrations/0009-schema-display-na
|
|
|
12
12
|
import { studioFaviconFileMigration } from './migrations/0010-studio-favicon-file.js';
|
|
13
13
|
import { rolePermissionActionsMigration } from './migrations/0011-role-permission-actions.js';
|
|
14
14
|
import { filesReadFiltersMigration } from './migrations/0012-files-read-filters.js';
|
|
15
|
+
import { externalAuthFoundationMigration } from './migrations/0013-external-auth-foundation.js';
|
|
15
16
|
import { readSchemaVersion } from './schema-version.js';
|
|
16
17
|
import { ensurePublicRole } from './setup.js';
|
|
17
18
|
|
|
@@ -28,6 +29,7 @@ export const CORE_MIGRATIONS = Object.freeze([
|
|
|
28
29
|
studioFaviconFileMigration,
|
|
29
30
|
rolePermissionActionsMigration,
|
|
30
31
|
filesReadFiltersMigration,
|
|
32
|
+
externalAuthFoundationMigration,
|
|
31
33
|
]);
|
|
32
34
|
|
|
33
35
|
export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
|
package/src/config.js
CHANGED
|
@@ -4,12 +4,10 @@ export const DEFAULT_SERVER_PORT = 3008;
|
|
|
4
4
|
|
|
5
5
|
function readInteger(value, fallback, name, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
6
6
|
if (value === undefined || value === '') return fallback;
|
|
7
|
-
|
|
8
7
|
const parsed = Number(value);
|
|
9
8
|
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
|
10
9
|
throw new Error(`${name} must be an integer between ${min} and ${max}`);
|
|
11
10
|
}
|
|
12
|
-
|
|
13
11
|
return parsed;
|
|
14
12
|
}
|
|
15
13
|
|
|
@@ -24,14 +22,72 @@ function readString(value, fallback = '') {
|
|
|
24
22
|
return value === undefined ? fallback : String(value);
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
function
|
|
28
|
-
const store = readString(value,
|
|
29
|
-
if (
|
|
30
|
-
throw new Error(
|
|
25
|
+
function readStore(value, fallback, name) {
|
|
26
|
+
const store = readString(value, fallback).trim().toLowerCase();
|
|
27
|
+
if (!['memory', 'redis'].includes(store)) {
|
|
28
|
+
throw new Error(`${name} must be memory or redis, received: ${store}`);
|
|
31
29
|
}
|
|
32
30
|
return store;
|
|
33
31
|
}
|
|
34
32
|
|
|
33
|
+
function readFailureMode(value, fallback = 'best-effort') {
|
|
34
|
+
const mode = readString(value, fallback).trim().toLowerCase();
|
|
35
|
+
if (!['best-effort', 'required'].includes(mode)) {
|
|
36
|
+
throw new Error(`RATE_LIMIT_FAILURE_MODE must be best-effort or required, received: ${mode}`);
|
|
37
|
+
}
|
|
38
|
+
return mode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readOriginList(value, fallback) {
|
|
42
|
+
const entries = readString(value, '')
|
|
43
|
+
.split(',')
|
|
44
|
+
.map((entry) => entry.trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
const source = entries.length > 0 ? entries : [fallback];
|
|
47
|
+
return [...new Set(source.map((entry) => {
|
|
48
|
+
let url;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(entry);
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error(`MCP_ALLOWED_ORIGINS contains an invalid URL: ${entry}`);
|
|
53
|
+
}
|
|
54
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
55
|
+
throw new Error(`MCP_ALLOWED_ORIGINS must use http or https: ${entry}`);
|
|
56
|
+
}
|
|
57
|
+
return url.origin;
|
|
58
|
+
}))];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readHostList(value, fallbackOrigin) {
|
|
62
|
+
let fallbackHost;
|
|
63
|
+
try {
|
|
64
|
+
fallbackHost = new URL(fallbackOrigin).host.toLowerCase();
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error(`Cannot derive MCP host from STUDIO_ORIGIN: ${fallbackOrigin}`);
|
|
67
|
+
}
|
|
68
|
+
const entries = readString(value, '')
|
|
69
|
+
.split(',')
|
|
70
|
+
.map((entry) => entry.trim())
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
const source = entries.length > 0 ? entries : [fallbackHost];
|
|
73
|
+
|
|
74
|
+
return [...new Set(source.map((entry) => {
|
|
75
|
+
if (/[:][/][/]|[\\/?#@\s\0]/.test(entry)) {
|
|
76
|
+
throw new Error(`MCP_ALLOWED_HOSTS contains an invalid host: ${entry}`);
|
|
77
|
+
}
|
|
78
|
+
let url;
|
|
79
|
+
try {
|
|
80
|
+
url = new URL(`http://${entry}`);
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(`MCP_ALLOWED_HOSTS contains an invalid host: ${entry}`);
|
|
83
|
+
}
|
|
84
|
+
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash || !url.host) {
|
|
85
|
+
throw new Error(`MCP_ALLOWED_HOSTS contains an invalid host: ${entry}`);
|
|
86
|
+
}
|
|
87
|
+
return url.host.toLowerCase();
|
|
88
|
+
}))];
|
|
89
|
+
}
|
|
90
|
+
|
|
35
91
|
export function loadEnvFileIfPresent(path = '.env') {
|
|
36
92
|
try {
|
|
37
93
|
loadEnvFile(path);
|
|
@@ -45,6 +101,13 @@ export function loadEnvFileIfPresent(path = '.env') {
|
|
|
45
101
|
export function loadConfig(env = process.env) {
|
|
46
102
|
const serverPort = readInteger(env.PORT, DEFAULT_SERVER_PORT, 'PORT', { min: 1, max: 65535 });
|
|
47
103
|
const studioOrigin = readString(env.STUDIO_ORIGIN, `http://localhost:${serverPort}`);
|
|
104
|
+
const cacheStore = readStore(env.CACHE_STORE, 'memory', 'CACHE_STORE');
|
|
105
|
+
const apiRateLimitStore = readStore(env.API_RATE_LIMIT_STORE, 'memory', 'API_RATE_LIMIT_STORE');
|
|
106
|
+
const authRateLimitStore = readStore(env.AUTH_RATE_LIMIT_STORE, apiRateLimitStore, 'AUTH_RATE_LIMIT_STORE');
|
|
107
|
+
const redisUrl = readString(env.REDIS_URL, '') || null;
|
|
108
|
+
if ([cacheStore, apiRateLimitStore, authRateLimitStore].includes('redis') && !redisUrl) {
|
|
109
|
+
throw new Error('REDIS_URL is required when any cache or rate-limit store is redis');
|
|
110
|
+
}
|
|
48
111
|
|
|
49
112
|
return {
|
|
50
113
|
server: {
|
|
@@ -54,49 +117,41 @@ export function loadConfig(env = process.env) {
|
|
|
54
117
|
trustProxyHops: readInteger(env.TRUST_PROXY_HOPS, 0, 'TRUST_PROXY_HOPS', { min: 0, max: 10 }),
|
|
55
118
|
rateLimit: {
|
|
56
119
|
enabled: readBoolean(env.API_RATE_LIMIT_ENABLED, true),
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}),
|
|
61
|
-
|
|
62
|
-
min: 1,
|
|
63
|
-
max: 1_000_000,
|
|
64
|
-
}),
|
|
65
|
-
maxBuckets: readInteger(env.API_RATE_LIMIT_MAX_BUCKETS, 10_000, 'API_RATE_LIMIT_MAX_BUCKETS', {
|
|
66
|
-
min: 1,
|
|
67
|
-
max: 1_000_000,
|
|
68
|
-
}),
|
|
120
|
+
store: apiRateLimitStore,
|
|
121
|
+
failureMode: readFailureMode(env.RATE_LIMIT_FAILURE_MODE),
|
|
122
|
+
windowMs: readInteger(env.API_RATE_LIMIT_WINDOW_MS, 60_000, 'API_RATE_LIMIT_WINDOW_MS', { min: 1000, max: 24 * 60 * 60 * 1000 }),
|
|
123
|
+
max: readInteger(env.API_RATE_LIMIT_MAX, 300, 'API_RATE_LIMIT_MAX', { min: 1, max: 1_000_000 }),
|
|
124
|
+
maxBuckets: readInteger(env.API_RATE_LIMIT_MAX_BUCKETS, 10_000, 'API_RATE_LIMIT_MAX_BUCKETS', { min: 1, max: 1_000_000 }),
|
|
69
125
|
},
|
|
70
126
|
pressure: {
|
|
71
127
|
enabled: readBoolean(env.PRESSURE_LIMIT_ENABLED, true),
|
|
72
|
-
maxConcurrent: readInteger(env.PRESSURE_MAX_CONCURRENT, 250, 'PRESSURE_MAX_CONCURRENT', {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}),
|
|
76
|
-
maxHeapPercent: readInteger(env.PRESSURE_MAX_HEAP_PERCENT, 95, 'PRESSURE_MAX_HEAP_PERCENT', {
|
|
77
|
-
min: 1,
|
|
78
|
-
max: 100,
|
|
79
|
-
}),
|
|
80
|
-
retryAfterSeconds: readInteger(env.PRESSURE_RETRY_AFTER_SECONDS, 1, 'PRESSURE_RETRY_AFTER_SECONDS', {
|
|
81
|
-
min: 1,
|
|
82
|
-
max: 3600,
|
|
83
|
-
}),
|
|
128
|
+
maxConcurrent: readInteger(env.PRESSURE_MAX_CONCURRENT, 250, 'PRESSURE_MAX_CONCURRENT', { min: 1, max: 100_000 }),
|
|
129
|
+
maxHeapPercent: readInteger(env.PRESSURE_MAX_HEAP_PERCENT, 95, 'PRESSURE_MAX_HEAP_PERCENT', { min: 1, max: 100 }),
|
|
130
|
+
retryAfterSeconds: readInteger(env.PRESSURE_RETRY_AFTER_SECONDS, 1, 'PRESSURE_RETRY_AFTER_SECONDS', { min: 1, max: 3600 }),
|
|
84
131
|
},
|
|
85
132
|
},
|
|
86
|
-
logging: {
|
|
87
|
-
|
|
133
|
+
logging: { level: readString(env.LOG_LEVEL, 'info') },
|
|
134
|
+
mcp: {
|
|
135
|
+
enabled: readBoolean(env.MCP_ENABLED, false),
|
|
136
|
+
writesEnabled: readBoolean(env.MCP_WRITES_ENABLED, false),
|
|
137
|
+
requireAuthentication: readBoolean(env.MCP_REQUIRE_AUTHENTICATION, true),
|
|
138
|
+
maxItems: readInteger(env.MCP_MAX_ITEMS, 100, 'MCP_MAX_ITEMS', { min: 1, max: 500 }),
|
|
139
|
+
maxResultBytes: readInteger(env.MCP_MAX_RESULT_BYTES, 1_000_000, 'MCP_MAX_RESULT_BYTES', { min: 10_000, max: 10_000_000 }),
|
|
140
|
+
allowedOrigins: readOriginList(env.MCP_ALLOWED_ORIGINS, studioOrigin),
|
|
141
|
+
allowedHosts: readHostList(env.MCP_ALLOWED_HOSTS, studioOrigin),
|
|
88
142
|
},
|
|
89
143
|
cache: {
|
|
90
144
|
enabled: readBoolean(env.CACHE_ENABLED, true),
|
|
91
|
-
store:
|
|
92
|
-
ttlMs: readInteger(env.CACHE_TTL_MS, 30_000, 'CACHE_TTL_MS', {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}),
|
|
145
|
+
store: cacheStore,
|
|
146
|
+
ttlMs: readInteger(env.CACHE_TTL_MS, 30_000, 'CACHE_TTL_MS', { min: 1, max: 24 * 60 * 60 * 1000 }),
|
|
147
|
+
maxEntries: readInteger(env.CACHE_MAX_ENTRIES, 5_000, 'CACHE_MAX_ENTRIES', { min: 1, max: 1_000_000 }),
|
|
148
|
+
},
|
|
149
|
+
redis: {
|
|
150
|
+
url: redisUrl,
|
|
151
|
+
prefix: readString(env.REDIS_PREFIX, 'yuncms:default:'),
|
|
152
|
+
required: readBoolean(env.REDIS_REQUIRED, false),
|
|
153
|
+
connectTimeoutMs: readInteger(env.REDIS_CONNECT_TIMEOUT_MS, 5_000, 'REDIS_CONNECT_TIMEOUT_MS', { min: 100, max: 60_000 }),
|
|
154
|
+
commandTimeoutMs: readInteger(env.REDIS_COMMAND_TIMEOUT_MS, 3_000, 'REDIS_COMMAND_TIMEOUT_MS', { min: 100, max: 60_000 }),
|
|
100
155
|
},
|
|
101
156
|
database: {
|
|
102
157
|
host: readString(env.DB_HOST, '127.0.0.1'),
|
|
@@ -109,10 +164,7 @@ export function loadConfig(env = process.env) {
|
|
|
109
164
|
},
|
|
110
165
|
storage: {
|
|
111
166
|
localRoot: readString(env.FILES_LOCAL_ROOT, '.yuncms/uploads'),
|
|
112
|
-
maxUploadBytes: readInteger(env.FILES_MAX_UPLOAD_BYTES, 25 * 1024 * 1024, 'FILES_MAX_UPLOAD_BYTES', {
|
|
113
|
-
min: 1,
|
|
114
|
-
max: 1024 * 1024 * 1024,
|
|
115
|
-
}),
|
|
167
|
+
maxUploadBytes: readInteger(env.FILES_MAX_UPLOAD_BYTES, 25 * 1024 * 1024, 'FILES_MAX_UPLOAD_BYTES', { min: 1, max: 1024 * 1024 * 1024 }),
|
|
116
168
|
s3: {
|
|
117
169
|
bucket: readString(env.S3_BUCKET, ''),
|
|
118
170
|
region: readString(env.S3_REGION, 'us-east-1'),
|
|
@@ -123,18 +175,9 @@ export function loadConfig(env = process.env) {
|
|
|
123
175
|
},
|
|
124
176
|
},
|
|
125
177
|
audit: {
|
|
126
|
-
retentionDays: readInteger(env.AUDIT_RETENTION_DAYS, 90, 'AUDIT_RETENTION_DAYS', {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}),
|
|
130
|
-
cleanupBatchSize: readInteger(env.AUDIT_CLEANUP_BATCH_SIZE, 1000, 'AUDIT_CLEANUP_BATCH_SIZE', {
|
|
131
|
-
min: 1,
|
|
132
|
-
max: 5000,
|
|
133
|
-
}),
|
|
134
|
-
cleanupMaxBatches: readInteger(env.AUDIT_CLEANUP_MAX_BATCHES, 100, 'AUDIT_CLEANUP_MAX_BATCHES', {
|
|
135
|
-
min: 1,
|
|
136
|
-
max: 1000,
|
|
137
|
-
}),
|
|
178
|
+
retentionDays: readInteger(env.AUDIT_RETENTION_DAYS, 90, 'AUDIT_RETENTION_DAYS', { min: 1, max: 3650 }),
|
|
179
|
+
cleanupBatchSize: readInteger(env.AUDIT_CLEANUP_BATCH_SIZE, 1000, 'AUDIT_CLEANUP_BATCH_SIZE', { min: 1, max: 5000 }),
|
|
180
|
+
cleanupMaxBatches: readInteger(env.AUDIT_CLEANUP_MAX_BATCHES, 100, 'AUDIT_CLEANUP_MAX_BATCHES', { min: 1, max: 1000 }),
|
|
138
181
|
},
|
|
139
182
|
mail: {
|
|
140
183
|
host: readString(env.SMTP_HOST, ''),
|
|
@@ -147,6 +190,8 @@ export function loadConfig(env = process.env) {
|
|
|
147
190
|
auth: {
|
|
148
191
|
publicUrl: readString(env.AUTH_PUBLIC_URL, studioOrigin).replace(/\/$/, ''),
|
|
149
192
|
rateLimit: {
|
|
193
|
+
store: authRateLimitStore,
|
|
194
|
+
failureMode: readFailureMode(env.RATE_LIMIT_FAILURE_MODE),
|
|
150
195
|
loginWindowMs: readInteger(env.AUTH_LOGIN_RATE_WINDOW_MS, 60_000, 'AUTH_LOGIN_RATE_WINDOW_MS', { min: 1000, max: 24 * 60 * 60 * 1000 }),
|
|
151
196
|
loginMax: readInteger(env.AUTH_LOGIN_RATE_MAX, 10, 'AUTH_LOGIN_RATE_MAX', { min: 1, max: 100_000 }),
|
|
152
197
|
refreshWindowMs: readInteger(env.AUTH_REFRESH_RATE_WINDOW_MS, 60_000, 'AUTH_REFRESH_RATE_WINDOW_MS', { min: 1000, max: 24 * 60 * 60 * 1000 }),
|
package/src/hooks.js
CHANGED
|
@@ -7,49 +7,119 @@ function hookError(code, message) {
|
|
|
7
7
|
return error;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export const HOOK_EVENTS = Object.freeze({
|
|
11
|
+
itemQuery: 'items.query',
|
|
12
|
+
itemRead: 'items.read',
|
|
13
|
+
itemCreate: 'items.create',
|
|
14
|
+
itemUpdate: 'items.update',
|
|
15
|
+
itemDelete: 'items.delete',
|
|
16
|
+
fileCreate: 'files.create',
|
|
17
|
+
fileUpdate: 'files.update',
|
|
18
|
+
fileDelete: 'files.delete',
|
|
19
|
+
fileRead: 'files.read',
|
|
20
|
+
userCreate: 'users.create',
|
|
21
|
+
userUpdate: 'users.update',
|
|
22
|
+
userDelete: 'users.delete',
|
|
23
|
+
userPasswordUpdate: 'users.password.update',
|
|
24
|
+
roleCreate: 'roles.create',
|
|
25
|
+
roleUpdate: 'roles.update',
|
|
26
|
+
roleDelete: 'roles.delete',
|
|
27
|
+
permissionCreate: 'permissions.create',
|
|
28
|
+
permissionUpdate: 'permissions.update',
|
|
29
|
+
permissionDelete: 'permissions.delete',
|
|
30
|
+
schemaCollectionCreate: 'schema.collection.create',
|
|
31
|
+
schemaCollectionUpdate: 'schema.collection.update',
|
|
32
|
+
schemaCollectionDelete: 'schema.collection.delete',
|
|
33
|
+
schemaFieldCreate: 'schema.field.create',
|
|
34
|
+
schemaFieldUpdate: 'schema.field.update',
|
|
35
|
+
schemaFieldDelete: 'schema.field.delete',
|
|
36
|
+
schemaRelationCreate: 'schema.relation.create',
|
|
37
|
+
schemaRelationDelete: 'schema.relation.delete',
|
|
38
|
+
schemaChanged: 'schema.changed',
|
|
39
|
+
authLoginSuccess: 'auth.login.success',
|
|
40
|
+
authLoginFailed: 'auth.login.failed',
|
|
41
|
+
authRefreshSuccess: 'auth.refresh.success',
|
|
42
|
+
authLogout: 'auth.logout',
|
|
43
|
+
mailSend: 'mail.send',
|
|
44
|
+
mailSent: 'mail.sent',
|
|
45
|
+
mailFailed: 'mail.failed',
|
|
46
|
+
requestReceived: 'request.received',
|
|
47
|
+
requestCompleted: 'request.completed',
|
|
48
|
+
requestFailed: 'request.failed',
|
|
49
|
+
appBeforeStart: 'app.beforeStart',
|
|
50
|
+
appAfterStart: 'app.afterStart',
|
|
51
|
+
appBeforeStop: 'app.beforeStop',
|
|
52
|
+
appAfterStop: 'app.afterStop',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function normalizeRegistrationOptions(options = {}) {
|
|
56
|
+
const priority = options?.priority ?? 0;
|
|
57
|
+
if (!Number.isInteger(priority) || priority < -10_000 || priority > 10_000) {
|
|
58
|
+
throw new Error('Hook priority must be an integer between -10000 and 10000');
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
priority,
|
|
62
|
+
extensionId: typeof options?.extensionId === 'string' && options.extensionId.trim()
|
|
63
|
+
? options.extensionId.trim()
|
|
64
|
+
: 'core',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
10
68
|
export class HookEmitter {
|
|
11
|
-
constructor({ maxDepth = 12 } = {}) {
|
|
69
|
+
constructor({ maxDepth = 12, logger = console } = {}) {
|
|
12
70
|
if (!Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 100) {
|
|
13
71
|
throw new Error('Hook maxDepth must be an integer between 1 and 100');
|
|
14
72
|
}
|
|
15
73
|
|
|
16
74
|
this.maxDepth = maxDepth;
|
|
75
|
+
this.logger = logger;
|
|
17
76
|
this.filters = new Map();
|
|
18
77
|
this.actions = new Map();
|
|
19
78
|
this.initializers = new Map();
|
|
20
79
|
this.storage = new AsyncLocalStorage();
|
|
80
|
+
this.registrationIndex = 0;
|
|
21
81
|
}
|
|
22
82
|
|
|
23
|
-
registerFilter(event, handler) {
|
|
24
|
-
return this.#register(this.filters, event, handler);
|
|
83
|
+
registerFilter(event, handler, options = {}) {
|
|
84
|
+
return this.#register(this.filters, event, handler, options);
|
|
25
85
|
}
|
|
26
86
|
|
|
27
|
-
registerAction(event, handler) {
|
|
28
|
-
return this.#register(this.actions, event, handler);
|
|
87
|
+
registerAction(event, handler, options = {}) {
|
|
88
|
+
return this.#register(this.actions, event, handler, options);
|
|
29
89
|
}
|
|
30
90
|
|
|
31
|
-
registerInit(event, handler) {
|
|
32
|
-
return this.#register(this.initializers, event, handler);
|
|
91
|
+
registerInit(event, handler, options = {}) {
|
|
92
|
+
return this.#register(this.initializers, event, handler, options);
|
|
33
93
|
}
|
|
34
94
|
|
|
35
|
-
#register(map, event, handler) {
|
|
95
|
+
#register(map, event, handler, options) {
|
|
36
96
|
if (typeof event !== 'string' || event.trim() === '') throw new Error('Hook event is required');
|
|
37
97
|
if (typeof handler !== 'function') throw new Error(`Hook handler for ${event} must be a function`);
|
|
38
98
|
|
|
39
99
|
const name = event.trim();
|
|
100
|
+
const registration = {
|
|
101
|
+
handler,
|
|
102
|
+
...normalizeRegistrationOptions(options),
|
|
103
|
+
index: this.registrationIndex++,
|
|
104
|
+
};
|
|
40
105
|
const handlers = map.get(name) ?? [];
|
|
41
|
-
handlers.push(
|
|
106
|
+
handlers.push(registration);
|
|
107
|
+
handlers.sort((left, right) => (
|
|
108
|
+
right.priority - left.priority
|
|
109
|
+
|| left.extensionId.localeCompare(right.extensionId)
|
|
110
|
+
|| left.index - right.index
|
|
111
|
+
));
|
|
42
112
|
map.set(name, handlers);
|
|
43
113
|
|
|
44
114
|
return () => {
|
|
45
115
|
const active = map.get(name) ?? [];
|
|
46
|
-
const next = active.filter((candidate) => candidate !==
|
|
116
|
+
const next = active.filter((candidate) => candidate !== registration);
|
|
47
117
|
if (next.length === 0) map.delete(name);
|
|
48
118
|
else map.set(name, next);
|
|
49
119
|
};
|
|
50
120
|
}
|
|
51
121
|
|
|
52
|
-
#nextExecution(event) {
|
|
122
|
+
#nextExecution(event, registration = null) {
|
|
53
123
|
const current = this.storage.getStore();
|
|
54
124
|
const depth = (current?.depth ?? 0) + 1;
|
|
55
125
|
if (depth > this.maxDepth) {
|
|
@@ -62,12 +132,15 @@ export class HookEmitter {
|
|
|
62
132
|
return {
|
|
63
133
|
chainId: current?.chainId ?? randomUUID(),
|
|
64
134
|
depth,
|
|
135
|
+
stack: [...(current?.stack ?? []), event],
|
|
65
136
|
events: [...(current?.events ?? []), event],
|
|
137
|
+
originExtension: current?.originExtension ?? registration?.extensionId ?? null,
|
|
138
|
+
originEvent: current?.originEvent ?? event,
|
|
66
139
|
};
|
|
67
140
|
}
|
|
68
141
|
|
|
69
|
-
async #runWithExecution(event, operation) {
|
|
70
|
-
const execution = this.#nextExecution(event);
|
|
142
|
+
async #runWithExecution(event, registration, operation) {
|
|
143
|
+
const execution = this.#nextExecution(event, registration);
|
|
71
144
|
return this.storage.run(execution, () => operation(execution));
|
|
72
145
|
}
|
|
73
146
|
|
|
@@ -75,47 +148,59 @@ export class HookEmitter {
|
|
|
75
148
|
const handlers = this.filters.get(event) ?? [];
|
|
76
149
|
if (handlers.length === 0) return payload;
|
|
77
150
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const next = await handler(current, {
|
|
151
|
+
let current = payload;
|
|
152
|
+
for (const registration of handlers) {
|
|
153
|
+
current = await this.#runWithExecution(event, registration, async (execution) => {
|
|
154
|
+
const next = await registration.handler(current, {
|
|
82
155
|
...context,
|
|
83
156
|
hook: execution,
|
|
84
157
|
event,
|
|
158
|
+
extensionId: registration.extensionId,
|
|
85
159
|
});
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
160
|
+
return next === undefined ? current : next;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return current;
|
|
90
164
|
}
|
|
91
165
|
|
|
92
166
|
async action(event, payload, context = {}) {
|
|
93
167
|
const handlers = this.actions.get(event) ?? [];
|
|
94
168
|
if (handlers.length === 0) return;
|
|
95
169
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
await
|
|
99
|
-
|
|
100
|
-
|
|
170
|
+
for (const registration of handlers) {
|
|
171
|
+
try {
|
|
172
|
+
await this.#runWithExecution(event, registration, async (execution) => {
|
|
173
|
+
await registration.handler(payload, {
|
|
174
|
+
...context,
|
|
175
|
+
hook: execution,
|
|
176
|
+
event,
|
|
177
|
+
extensionId: registration.extensionId,
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
this.logger?.error?.('YunCMS extension action failed after successful lifecycle point', {
|
|
101
182
|
event,
|
|
183
|
+
extensionId: registration.extensionId,
|
|
184
|
+
code: error?.code ?? null,
|
|
185
|
+
message: error?.message ?? String(error),
|
|
102
186
|
});
|
|
103
187
|
}
|
|
104
|
-
}
|
|
188
|
+
}
|
|
105
189
|
}
|
|
106
190
|
|
|
107
191
|
async init(event, context = {}) {
|
|
108
192
|
const handlers = this.initializers.get(event) ?? [];
|
|
109
193
|
if (handlers.length === 0) return;
|
|
110
194
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
await handler({
|
|
195
|
+
for (const registration of handlers) {
|
|
196
|
+
await this.#runWithExecution(`init:${event}`, registration, async (execution) => {
|
|
197
|
+
await registration.handler({
|
|
114
198
|
...context,
|
|
115
199
|
hook: execution,
|
|
116
200
|
event,
|
|
201
|
+
extensionId: registration.extensionId,
|
|
117
202
|
});
|
|
118
|
-
}
|
|
119
|
-
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
120
205
|
}
|
|
121
206
|
}
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export { DEFAULT_SERVER_PORT, loadConfig, loadEnvFileIfPresent } from './config.js';
|
|
2
2
|
export { isCacheStore, MemoryCacheStore } from './cache.js';
|
|
3
|
+
export {
|
|
4
|
+
RedisClient,
|
|
5
|
+
RedisCacheStore,
|
|
6
|
+
RedisFixedWindowStore,
|
|
7
|
+
parseRedisUrl,
|
|
8
|
+
redactRedisUrl,
|
|
9
|
+
} from './redis.js';
|
|
3
10
|
export { createDatabasePool, pingDatabase, closeDatabasePool } from './database.js';
|
|
4
11
|
export { withTransaction, withConnectionTransaction } from './transaction.js';
|
|
5
12
|
export { assertIdentifier, quoteIdentifier } from './identifier.js';
|
|
@@ -19,22 +26,25 @@ export {
|
|
|
19
26
|
requireAccountability,
|
|
20
27
|
} from './accountability.js';
|
|
21
28
|
export { createRequestContext } from './context.js';
|
|
22
|
-
export {
|
|
23
|
-
|
|
24
|
-
findExistingAdmin,
|
|
25
|
-
findPublicRole,
|
|
26
|
-
ensurePublicRole,
|
|
27
|
-
} from './setup.js';
|
|
28
|
-
export { HookEmitter } from './hooks.js';
|
|
29
|
+
export { createInitialAdmin, findExistingAdmin, findPublicRole, ensurePublicRole } from './setup.js';
|
|
30
|
+
export { HookEmitter, HOOK_EVENTS } from './hooks.js';
|
|
29
31
|
export { createJsonLogger, LEVELS as LOG_LEVELS } from './logger.js';
|
|
30
32
|
export { deleteM2MJunction } from './m2m-lifecycle.js';
|
|
31
33
|
export { createO2ORelation, deleteO2ORelation, o2oUniqueIndexName } from './o2o-relation.js';
|
|
32
34
|
export {
|
|
33
35
|
MAX_EXPAND_FIELDS,
|
|
36
|
+
MAX_RELATION_DEPTH,
|
|
34
37
|
parseExpandInput,
|
|
35
38
|
readManyWithRelations,
|
|
36
39
|
readOneWithRelations,
|
|
37
40
|
} from './relation-expansion.js';
|
|
41
|
+
export {
|
|
42
|
+
assertLocalRedirectTarget,
|
|
43
|
+
createExternalAuthState,
|
|
44
|
+
hashExternalAuthState,
|
|
45
|
+
encryptExternalAuthSecret,
|
|
46
|
+
decryptExternalAuthSecret,
|
|
47
|
+
} from './auth/external-state.js';
|
|
38
48
|
export { SmtpMailer } from './mail/smtp-mailer.js';
|
|
39
49
|
export { LocalStorageDriver, assertStorageKey } from './storage/local-storage-driver.js';
|
|
40
50
|
export { S3StorageDriver } from './storage/s3-storage-driver.js';
|
|
@@ -44,6 +54,7 @@ export { createServiceRegistry } from './services/service-registry.js';
|
|
|
44
54
|
export { createCoreServiceRegistry } from './services/core-services.js';
|
|
45
55
|
export { AuthService } from './services/auth-service.js';
|
|
46
56
|
export { AuthTokensService } from './services/auth-tokens-service.js';
|
|
57
|
+
export { ExternalAuthService, AUTH_TRANSACTION_TTL_MS } from './services/external-auth-service.js';
|
|
47
58
|
export { ApiTokensService } from './services/api-tokens-service.js';
|
|
48
59
|
export { AuditService, redactAuditValue } from './services/audit-service.js';
|
|
49
60
|
export { ItemsService } from './services/items-service.js';
|
|
@@ -76,10 +87,15 @@ export {
|
|
|
76
87
|
systemPermissionConfig,
|
|
77
88
|
} from './system-permissions.js';
|
|
78
89
|
export {
|
|
90
|
+
QUERY_LIMITS,
|
|
79
91
|
parseItemsQuery,
|
|
92
|
+
queryCost,
|
|
93
|
+
assertQueryCost,
|
|
80
94
|
compileSelectFields,
|
|
81
95
|
compileSort,
|
|
82
96
|
compileFilter,
|
|
97
|
+
compileSearch,
|
|
98
|
+
compileAggregate,
|
|
83
99
|
} from './query.js';
|
|
84
100
|
export { withAdvisoryLock } from './advisory-lock.js';
|
|
85
101
|
export {
|