@yunsoft/yuncms-core 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-core",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Core services, schema, authentication, permissions and storage primitives for YunCMS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 readCacheStore(value) {
28
- const store = readString(value, 'memory').trim().toLowerCase();
29
- if (store !== 'memory') {
30
- throw new Error(`CACHE_STORE must be memory until a shared-store adapter is configured, received: ${store}`);
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
- windowMs: readInteger(env.API_RATE_LIMIT_WINDOW_MS, 60_000, 'API_RATE_LIMIT_WINDOW_MS', {
58
- min: 1000,
59
- max: 24 * 60 * 60 * 1000,
60
- }),
61
- max: readInteger(env.API_RATE_LIMIT_MAX, 300, 'API_RATE_LIMIT_MAX', {
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
- min: 1,
74
- max: 100_000,
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
- level: readString(env.LOG_LEVEL, 'info'),
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: readCacheStore(env.CACHE_STORE),
92
- ttlMs: readInteger(env.CACHE_TTL_MS, 30_000, 'CACHE_TTL_MS', {
93
- min: 1,
94
- max: 24 * 60 * 60 * 1000,
95
- }),
96
- maxEntries: readInteger(env.CACHE_MAX_ENTRIES, 5_000, 'CACHE_MAX_ENTRIES', {
97
- min: 1,
98
- max: 1_000_000,
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
- min: 1,
128
- max: 3650,
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(handler);
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 !== handler);
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
- return this.#runWithExecution(event, async (execution) => {
79
- let current = payload;
80
- for (const handler of handlers) {
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
- if (next !== undefined) current = next;
87
- }
88
- return current;
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
- await this.#runWithExecution(event, async (execution) => {
97
- for (const handler of handlers) {
98
- await handler(payload, {
99
- ...context,
100
- hook: execution,
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
- await this.#runWithExecution(`init:${event}`, async (execution) => {
112
- for (const handler of handlers) {
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
- createInitialAdmin,
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 {