@yunsoft/yuncms-core 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 +1 -1
- package/src/auth/external-state.js +76 -0
- package/src/bootstrap.js +6 -0
- package/src/cache.js +85 -0
- package/src/config.js +115 -20
- package/src/context.js +5 -2
- package/src/hooks.js +117 -32
- package/src/index.js +32 -7
- package/src/mail/smtp-mailer.js +58 -14
- package/src/maintenance-state.js +89 -0
- package/src/migrations/0011-role-permission-actions.js +15 -0
- package/src/migrations/0012-files-read-filters.js +14 -0
- package/src/migrations/0013-external-auth-foundation.js +35 -0
- package/src/migrations.js +113 -3
- package/src/query.js +156 -54
- package/src/redis.js +300 -0
- package/src/relation-expansion.js +564 -142
- 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/files-service.js +85 -7
- package/src/services/items-service.js +121 -81
- package/src/services/permissions-service.js +35 -15
- package/src/services/roles-service.js +44 -23
- package/src/services/users-service.js +27 -3
- package/src/system-permissions.js +50 -7
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
|
@@ -10,6 +10,9 @@ import { systemPermissionResourcesMigration } from './migrations/0007-system-per
|
|
|
10
10
|
import { studioLogoFileMigration } from './migrations/0008-studio-logo-file.js';
|
|
11
11
|
import { schemaDisplayNamesMigration } from './migrations/0009-schema-display-names.js';
|
|
12
12
|
import { studioFaviconFileMigration } from './migrations/0010-studio-favicon-file.js';
|
|
13
|
+
import { rolePermissionActionsMigration } from './migrations/0011-role-permission-actions.js';
|
|
14
|
+
import { filesReadFiltersMigration } from './migrations/0012-files-read-filters.js';
|
|
15
|
+
import { externalAuthFoundationMigration } from './migrations/0013-external-auth-foundation.js';
|
|
13
16
|
import { readSchemaVersion } from './schema-version.js';
|
|
14
17
|
import { ensurePublicRole } from './setup.js';
|
|
15
18
|
|
|
@@ -24,6 +27,9 @@ export const CORE_MIGRATIONS = Object.freeze([
|
|
|
24
27
|
studioLogoFileMigration,
|
|
25
28
|
schemaDisplayNamesMigration,
|
|
26
29
|
studioFaviconFileMigration,
|
|
30
|
+
rolePermissionActionsMigration,
|
|
31
|
+
filesReadFiltersMigration,
|
|
32
|
+
externalAuthFoundationMigration,
|
|
27
33
|
]);
|
|
28
34
|
|
|
29
35
|
export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
function cacheError(message) {
|
|
2
|
+
const error = new Error(message);
|
|
3
|
+
error.code = 'INVALID_CACHE_CONFIG';
|
|
4
|
+
return error;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isCacheStore(store) {
|
|
8
|
+
return Boolean(
|
|
9
|
+
store
|
|
10
|
+
&& typeof store.get === 'function'
|
|
11
|
+
&& typeof store.set === 'function'
|
|
12
|
+
&& typeof store.delete === 'function'
|
|
13
|
+
&& typeof store.clear === 'function',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MemoryCacheStore {
|
|
18
|
+
constructor({
|
|
19
|
+
ttlMs = 30_000,
|
|
20
|
+
maxEntries = 5_000,
|
|
21
|
+
now = () => Date.now(),
|
|
22
|
+
} = {}) {
|
|
23
|
+
if (!Number.isInteger(ttlMs) || ttlMs < 1) throw cacheError('Cache ttlMs must be a positive integer');
|
|
24
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1) {
|
|
25
|
+
throw cacheError('Cache maxEntries must be a positive integer');
|
|
26
|
+
}
|
|
27
|
+
if (typeof now !== 'function') throw cacheError('Cache now must be a function');
|
|
28
|
+
|
|
29
|
+
this.ttlMs = ttlMs;
|
|
30
|
+
this.maxEntries = maxEntries;
|
|
31
|
+
this.now = now;
|
|
32
|
+
this.entries = new Map();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#deleteExpired(timestamp) {
|
|
36
|
+
for (const [key, entry] of this.entries) {
|
|
37
|
+
if (entry.expiresAt <= timestamp) this.entries.delete(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#ensureCapacity(timestamp, incomingKey) {
|
|
42
|
+
this.#deleteExpired(timestamp);
|
|
43
|
+
if (this.entries.has(incomingKey)) return;
|
|
44
|
+
while (this.entries.size >= this.maxEntries) {
|
|
45
|
+
const oldestKey = this.entries.keys().next().value;
|
|
46
|
+
if (oldestKey === undefined) break;
|
|
47
|
+
this.entries.delete(oldestKey);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async get(key) {
|
|
52
|
+
const entry = this.entries.get(String(key));
|
|
53
|
+
if (!entry) return undefined;
|
|
54
|
+
if (entry.expiresAt <= this.now()) {
|
|
55
|
+
this.entries.delete(String(key));
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return entry.value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async set(key, value, { ttlMs = this.ttlMs } = {}) {
|
|
62
|
+
if (!Number.isInteger(ttlMs) || ttlMs < 1) throw cacheError('Cache entry ttlMs must be a positive integer');
|
|
63
|
+
const normalizedKey = String(key);
|
|
64
|
+
const timestamp = this.now();
|
|
65
|
+
this.#ensureCapacity(timestamp, normalizedKey);
|
|
66
|
+
this.entries.delete(normalizedKey);
|
|
67
|
+
this.entries.set(normalizedKey, {
|
|
68
|
+
value,
|
|
69
|
+
expiresAt: timestamp + ttlMs,
|
|
70
|
+
});
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async delete(key) {
|
|
75
|
+
return this.entries.delete(String(key));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async clear() {
|
|
79
|
+
this.entries.clear();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get size() {
|
|
83
|
+
return this.entries.size;
|
|
84
|
+
}
|
|
85
|
+
}
|
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,6 +22,72 @@ function readString(value, fallback = '') {
|
|
|
24
22
|
return value === undefined ? fallback : String(value);
|
|
25
23
|
}
|
|
26
24
|
|
|
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}`);
|
|
29
|
+
}
|
|
30
|
+
return store;
|
|
31
|
+
}
|
|
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
|
+
|
|
27
91
|
export function loadEnvFileIfPresent(path = '.env') {
|
|
28
92
|
try {
|
|
29
93
|
loadEnvFile(path);
|
|
@@ -37,6 +101,13 @@ export function loadEnvFileIfPresent(path = '.env') {
|
|
|
37
101
|
export function loadConfig(env = process.env) {
|
|
38
102
|
const serverPort = readInteger(env.PORT, DEFAULT_SERVER_PORT, 'PORT', { min: 1, max: 65535 });
|
|
39
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
|
+
}
|
|
40
111
|
|
|
41
112
|
return {
|
|
42
113
|
server: {
|
|
@@ -44,9 +115,43 @@ export function loadConfig(env = process.env) {
|
|
|
44
115
|
port: serverPort,
|
|
45
116
|
studioOrigin,
|
|
46
117
|
trustProxyHops: readInteger(env.TRUST_PROXY_HOPS, 0, 'TRUST_PROXY_HOPS', { min: 0, max: 10 }),
|
|
118
|
+
rateLimit: {
|
|
119
|
+
enabled: readBoolean(env.API_RATE_LIMIT_ENABLED, true),
|
|
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 }),
|
|
125
|
+
},
|
|
126
|
+
pressure: {
|
|
127
|
+
enabled: readBoolean(env.PRESSURE_LIMIT_ENABLED, true),
|
|
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 }),
|
|
131
|
+
},
|
|
132
|
+
},
|
|
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),
|
|
142
|
+
},
|
|
143
|
+
cache: {
|
|
144
|
+
enabled: readBoolean(env.CACHE_ENABLED, true),
|
|
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 }),
|
|
47
148
|
},
|
|
48
|
-
|
|
49
|
-
|
|
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 }),
|
|
50
155
|
},
|
|
51
156
|
database: {
|
|
52
157
|
host: readString(env.DB_HOST, '127.0.0.1'),
|
|
@@ -59,10 +164,7 @@ export function loadConfig(env = process.env) {
|
|
|
59
164
|
},
|
|
60
165
|
storage: {
|
|
61
166
|
localRoot: readString(env.FILES_LOCAL_ROOT, '.yuncms/uploads'),
|
|
62
|
-
maxUploadBytes: readInteger(env.FILES_MAX_UPLOAD_BYTES, 25 * 1024 * 1024, 'FILES_MAX_UPLOAD_BYTES', {
|
|
63
|
-
min: 1,
|
|
64
|
-
max: 1024 * 1024 * 1024,
|
|
65
|
-
}),
|
|
167
|
+
maxUploadBytes: readInteger(env.FILES_MAX_UPLOAD_BYTES, 25 * 1024 * 1024, 'FILES_MAX_UPLOAD_BYTES', { min: 1, max: 1024 * 1024 * 1024 }),
|
|
66
168
|
s3: {
|
|
67
169
|
bucket: readString(env.S3_BUCKET, ''),
|
|
68
170
|
region: readString(env.S3_REGION, 'us-east-1'),
|
|
@@ -73,18 +175,9 @@ export function loadConfig(env = process.env) {
|
|
|
73
175
|
},
|
|
74
176
|
},
|
|
75
177
|
audit: {
|
|
76
|
-
retentionDays: readInteger(env.AUDIT_RETENTION_DAYS, 90, 'AUDIT_RETENTION_DAYS', {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}),
|
|
80
|
-
cleanupBatchSize: readInteger(env.AUDIT_CLEANUP_BATCH_SIZE, 1000, 'AUDIT_CLEANUP_BATCH_SIZE', {
|
|
81
|
-
min: 1,
|
|
82
|
-
max: 5000,
|
|
83
|
-
}),
|
|
84
|
-
cleanupMaxBatches: readInteger(env.AUDIT_CLEANUP_MAX_BATCHES, 100, 'AUDIT_CLEANUP_MAX_BATCHES', {
|
|
85
|
-
min: 1,
|
|
86
|
-
max: 1000,
|
|
87
|
-
}),
|
|
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 }),
|
|
88
181
|
},
|
|
89
182
|
mail: {
|
|
90
183
|
host: readString(env.SMTP_HOST, ''),
|
|
@@ -97,6 +190,8 @@ export function loadConfig(env = process.env) {
|
|
|
97
190
|
auth: {
|
|
98
191
|
publicUrl: readString(env.AUTH_PUBLIC_URL, studioOrigin).replace(/\/$/, ''),
|
|
99
192
|
rateLimit: {
|
|
193
|
+
store: authRateLimitStore,
|
|
194
|
+
failureMode: readFailureMode(env.RATE_LIMIT_FAILURE_MODE),
|
|
100
195
|
loginWindowMs: readInteger(env.AUTH_LOGIN_RATE_WINDOW_MS, 60_000, 'AUTH_LOGIN_RATE_WINDOW_MS', { min: 1000, max: 24 * 60 * 60 * 1000 }),
|
|
101
196
|
loginMax: readInteger(env.AUTH_LOGIN_RATE_MAX, 10, 'AUTH_LOGIN_RATE_MAX', { min: 1, max: 100_000 }),
|
|
102
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/context.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requireAccountability } from './accountability.js';
|
|
2
|
+
import { isCacheStore } from './cache.js';
|
|
2
3
|
|
|
3
4
|
export function createRequestContext({
|
|
4
5
|
accountability,
|
|
@@ -9,14 +10,16 @@ export function createRequestContext({
|
|
|
9
10
|
env = {},
|
|
10
11
|
emitter = null,
|
|
11
12
|
storage = null,
|
|
12
|
-
permissionCache =
|
|
13
|
+
permissionCache = null,
|
|
13
14
|
requestId = null,
|
|
14
15
|
} = {}) {
|
|
15
16
|
requireAccountability(accountability);
|
|
16
17
|
|
|
17
18
|
if (!services) throw new Error('Service registry is required');
|
|
18
19
|
if (!database) throw new Error('Database handle is required');
|
|
19
|
-
if (
|
|
20
|
+
if (permissionCache !== null && !isCacheStore(permissionCache)) {
|
|
21
|
+
throw new Error('Permission cache must implement the cache-store contract');
|
|
22
|
+
}
|
|
20
23
|
|
|
21
24
|
return Object.freeze({
|
|
22
25
|
accountability,
|
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
|
}
|