@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/src/index.js CHANGED
@@ -1,10 +1,24 @@
1
1
  export { DEFAULT_SERVER_PORT, loadConfig, loadEnvFileIfPresent } from './config.js';
2
+ export { isCacheStore, MemoryCacheStore } from './cache.js';
3
+ export {
4
+ RedisClient,
5
+ RedisCacheStore,
6
+ RedisFixedWindowStore,
7
+ parseRedisUrl,
8
+ redactRedisUrl,
9
+ } from './redis.js';
2
10
  export { createDatabasePool, pingDatabase, closeDatabasePool } from './database.js';
3
11
  export { withTransaction, withConnectionTransaction } from './transaction.js';
4
12
  export { assertIdentifier, quoteIdentifier } from './identifier.js';
5
13
  export { normalizeDisplayName, normalizeSchemaKey, resolveSchemaName } from './schema-key.js';
6
14
  export { YunCmsDatabaseError, normalizeDatabaseError, isRetryableDatabaseError } from './errors.js';
7
15
  export { withDatabaseRetry } from './retry.js';
16
+ export {
17
+ MAINTENANCE_BYPASS_ENV,
18
+ maintenanceLockPath,
19
+ hashMaintenanceBypassToken,
20
+ assertMaintenanceStartupAllowed,
21
+ } from './maintenance-state.js';
8
22
  export {
9
23
  createAccountability,
10
24
  createPublicAccountability,
@@ -12,22 +26,25 @@ export {
12
26
  requireAccountability,
13
27
  } from './accountability.js';
14
28
  export { createRequestContext } from './context.js';
15
- export {
16
- createInitialAdmin,
17
- findExistingAdmin,
18
- findPublicRole,
19
- ensurePublicRole,
20
- } from './setup.js';
21
- export { HookEmitter } from './hooks.js';
29
+ export { createInitialAdmin, findExistingAdmin, findPublicRole, ensurePublicRole } from './setup.js';
30
+ export { HookEmitter, HOOK_EVENTS } from './hooks.js';
22
31
  export { createJsonLogger, LEVELS as LOG_LEVELS } from './logger.js';
23
32
  export { deleteM2MJunction } from './m2m-lifecycle.js';
24
33
  export { createO2ORelation, deleteO2ORelation, o2oUniqueIndexName } from './o2o-relation.js';
25
34
  export {
26
35
  MAX_EXPAND_FIELDS,
36
+ MAX_RELATION_DEPTH,
27
37
  parseExpandInput,
28
38
  readManyWithRelations,
29
39
  readOneWithRelations,
30
40
  } from './relation-expansion.js';
41
+ export {
42
+ assertLocalRedirectTarget,
43
+ createExternalAuthState,
44
+ hashExternalAuthState,
45
+ encryptExternalAuthSecret,
46
+ decryptExternalAuthSecret,
47
+ } from './auth/external-state.js';
31
48
  export { SmtpMailer } from './mail/smtp-mailer.js';
32
49
  export { LocalStorageDriver, assertStorageKey } from './storage/local-storage-driver.js';
33
50
  export { S3StorageDriver } from './storage/s3-storage-driver.js';
@@ -37,6 +54,7 @@ export { createServiceRegistry } from './services/service-registry.js';
37
54
  export { createCoreServiceRegistry } from './services/core-services.js';
38
55
  export { AuthService } from './services/auth-service.js';
39
56
  export { AuthTokensService } from './services/auth-tokens-service.js';
57
+ export { ExternalAuthService, AUTH_TRANSACTION_TTL_MS } from './services/external-auth-service.js';
40
58
  export { ApiTokensService } from './services/api-tokens-service.js';
41
59
  export { AuditService, redactAuditValue } from './services/audit-service.js';
42
60
  export { ItemsService } from './services/items-service.js';
@@ -63,20 +81,27 @@ export {
63
81
  } from './system-fields.js';
64
82
  export {
65
83
  assertActionOnlyPermissionPayload,
84
+ assertSystemPermissionPayload,
66
85
  assertSystemResourceAction,
67
86
  isPermissionManagedSystemResource,
68
87
  systemPermissionConfig,
69
88
  } from './system-permissions.js';
70
89
  export {
90
+ QUERY_LIMITS,
71
91
  parseItemsQuery,
92
+ queryCost,
93
+ assertQueryCost,
72
94
  compileSelectFields,
73
95
  compileSort,
74
96
  compileFilter,
97
+ compileSearch,
98
+ compileAggregate,
75
99
  } from './query.js';
76
100
  export { withAdvisoryLock } from './advisory-lock.js';
77
101
  export {
78
102
  ensureMigrationJournal,
79
103
  readAppliedMigrations,
104
+ readMigrationAttempts,
80
105
  validateMigration,
81
106
  applyMigrations,
82
107
  assertMigrationsApplied,
@@ -13,6 +13,25 @@ function assertAddress(value, label) {
13
13
  return value.trim();
14
14
  }
15
15
 
16
+ function normalizeMessage({ to, subject, text, html = undefined } = {}) {
17
+ const recipient = assertAddress(to, 'Recipient');
18
+ if (typeof subject !== 'string' || !subject.trim() || /[\r\n]/.test(subject)) {
19
+ throw mailError('INVALID_MAIL_MESSAGE', 'Mail subject is invalid');
20
+ }
21
+ if (typeof text !== 'string' || !text) {
22
+ throw mailError('INVALID_MAIL_MESSAGE', 'Mail text body is required');
23
+ }
24
+ if (html !== undefined && typeof html !== 'string') {
25
+ throw mailError('INVALID_MAIL_MESSAGE', 'Mail HTML body must be a string');
26
+ }
27
+ return {
28
+ to: recipient,
29
+ subject: subject.trim(),
30
+ text,
31
+ ...(html ? { html } : {}),
32
+ };
33
+ }
34
+
16
35
  export class SmtpMailer {
17
36
  constructor({
18
37
  host,
@@ -22,9 +41,11 @@ export class SmtpMailer {
22
41
  password = null,
23
42
  from,
24
43
  transport = null,
44
+ emitter = null,
25
45
  } = {}) {
26
46
  if (!transport && (!host || typeof host !== 'string')) throw new Error('SMTP host is required');
27
47
  this.from = assertAddress(from, 'SMTP from address');
48
+ this.emitter = emitter;
28
49
  this.transport = transport ?? nodemailer.createTransport({
29
50
  host,
30
51
  port,
@@ -40,26 +61,49 @@ export class SmtpMailer {
40
61
  });
41
62
  }
42
63
 
64
+ setEmitter(emitter) {
65
+ this.emitter = emitter;
66
+ return this;
67
+ }
68
+
43
69
  async verify() {
44
70
  if (typeof this.transport.verify !== 'function') return true;
45
71
  return this.transport.verify();
46
72
  }
47
73
 
48
- async send({ to, subject, text, html = undefined } = {}) {
49
- const recipient = assertAddress(to, 'Recipient');
50
- if (typeof subject !== 'string' || !subject.trim() || /[\r\n]/.test(subject)) {
51
- throw mailError('INVALID_MAIL_MESSAGE', 'Mail subject is invalid');
52
- }
53
- if (typeof text !== 'string' || !text) {
54
- throw mailError('INVALID_MAIL_MESSAGE', 'Mail text body is required');
74
+ async send(message = {}, context = {}) {
75
+ let normalized = normalizeMessage(message);
76
+ if (this.emitter) {
77
+ normalized = normalizeMessage(await this.emitter.filter('mail.send', normalized, {
78
+ accountability: context.accountability ?? null,
79
+ requestId: context.requestId ?? null,
80
+ }));
55
81
  }
56
82
 
57
- return this.transport.sendMail({
58
- from: this.from,
59
- to: recipient,
60
- subject: subject.trim(),
61
- text,
62
- ...(html ? { html } : {}),
63
- });
83
+ try {
84
+ const result = await this.transport.sendMail({
85
+ from: this.from,
86
+ ...normalized,
87
+ });
88
+ await this.emitter?.action('mail.sent', {
89
+ to: normalized.to,
90
+ subject: normalized.subject,
91
+ messageId: result?.messageId ?? null,
92
+ }, {
93
+ accountability: context.accountability ?? null,
94
+ requestId: context.requestId ?? null,
95
+ });
96
+ return result;
97
+ } catch (error) {
98
+ await this.emitter?.action('mail.failed', {
99
+ to: normalized.to,
100
+ subject: normalized.subject,
101
+ code: error?.code ?? 'MAIL_DELIVERY_FAILED',
102
+ }, {
103
+ accountability: context.accountability ?? null,
104
+ requestId: context.requestId ?? null,
105
+ });
106
+ throw error;
107
+ }
64
108
  }
65
109
  }
@@ -0,0 +1,89 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ import { realpathSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+
7
+ export const MAINTENANCE_BYPASS_ENV = 'YUNCMS_MAINTENANCE_BYPASS_TOKEN';
8
+
9
+ function sha256(value) {
10
+ return createHash('sha256').update(String(value)).digest('hex');
11
+ }
12
+
13
+ export function canonicalProjectPath(cwd = process.cwd()) {
14
+ const absolute = resolve(cwd);
15
+ try {
16
+ return realpathSync.native ? realpathSync.native(absolute) : realpathSync(absolute);
17
+ } catch {
18
+ return absolute;
19
+ }
20
+ }
21
+
22
+ export function maintenanceLockPath(cwd = process.cwd()) {
23
+ const projectKey = sha256(canonicalProjectPath(cwd)).slice(0, 32);
24
+ return join(tmpdir(), 'yuncms-update-locks', `${projectKey}.lock`);
25
+ }
26
+
27
+ export function hashMaintenanceBypassToken(token) {
28
+ if (typeof token !== 'string' || token.length < 32) {
29
+ const error = new Error('Maintenance bypass token must contain at least 32 characters');
30
+ error.code = 'MAINTENANCE_BYPASS_TOKEN_INVALID';
31
+ throw error;
32
+ }
33
+ return sha256(token);
34
+ }
35
+
36
+ function hashesEqual(left, right) {
37
+ if (typeof left !== 'string' || typeof right !== 'string') return false;
38
+ if (!/^[0-9a-f]{64}$/i.test(left) || !/^[0-9a-f]{64}$/i.test(right)) return false;
39
+ const leftBuffer = Buffer.from(left, 'hex');
40
+ const rightBuffer = Buffer.from(right, 'hex');
41
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
42
+ }
43
+
44
+ async function readMaintenanceState(path, readFileFn) {
45
+ let text;
46
+ try {
47
+ text = await readFileFn(path, 'utf8');
48
+ } catch (error) {
49
+ if (error?.code === 'ENOENT') return null;
50
+ throw error;
51
+ }
52
+
53
+ try {
54
+ const state = JSON.parse(text);
55
+ if (!state || typeof state !== 'object' || Array.isArray(state)) throw new Error('invalid state');
56
+ return state;
57
+ } catch (cause) {
58
+ const error = new Error(`YunCMS maintenance lock is unreadable or invalid: ${path}`);
59
+ error.code = 'YUNCMS_MAINTENANCE_ACTIVE';
60
+ error.lockPath = path;
61
+ error.cause = cause;
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ export async function assertMaintenanceStartupAllowed({
67
+ cwd = process.cwd(),
68
+ env = process.env,
69
+ readFileFn = readFile,
70
+ } = {}) {
71
+ const path = maintenanceLockPath(cwd);
72
+ const state = await readMaintenanceState(path, readFileFn);
73
+ if (!state) return true;
74
+
75
+ const suppliedToken = env?.[MAINTENANCE_BYPASS_ENV];
76
+ if (typeof suppliedToken === 'string' && suppliedToken.length >= 32) {
77
+ const suppliedHash = sha256(suppliedToken);
78
+ if (hashesEqual(suppliedHash, state.bypassTokenHash)) return true;
79
+ }
80
+
81
+ const error = new Error(
82
+ `YunCMS maintenance is active for this project. Do not start the application until the maintenance operation finishes: ${path}`,
83
+ );
84
+ error.code = 'YUNCMS_MAINTENANCE_ACTIVE';
85
+ error.lockPath = path;
86
+ error.startedAt = typeof state.startedAt === 'string' ? state.startedAt : null;
87
+ error.pid = Number.isInteger(state.pid) ? state.pid : null;
88
+ throw error;
89
+ }
@@ -0,0 +1,15 @@
1
+ export const rolePermissionActionsMigration = {
2
+ id: '0011-role-permission-actions',
3
+ statements: [
4
+ `UPDATE yuncms_collections
5
+ SET metadata = JSON_SET(
6
+ COALESCE(metadata, JSON_OBJECT()),
7
+ '$.permissionManaged', TRUE,
8
+ '$.permissionMode', 'action-only',
9
+ '$.resource', 'roles',
10
+ '$.allowedActions', JSON_ARRAY('read', 'create', 'update', 'delete')
11
+ )
12
+ WHERE collection = 'yuncms_roles' AND \`system\` = 1`,
13
+ `UPDATE yuncms_schema_state SET version = version + 1 WHERE id = 1`,
14
+ ],
15
+ };
@@ -0,0 +1,14 @@
1
+ export const filesReadFiltersMigration = {
2
+ id: '0012-files-read-filters',
3
+ statements: [
4
+ `UPDATE yuncms_collections
5
+ SET metadata = JSON_SET(
6
+ COALESCE(metadata, JSON_OBJECT()),
7
+ '$.permissionMode',
8
+ 'filter-read'
9
+ )
10
+ WHERE collection = 'yuncms_files'
11
+ AND \`system\` = 1`,
12
+ `UPDATE yuncms_schema_state SET version = version + 1 WHERE id = 1`,
13
+ ],
14
+ };
@@ -0,0 +1,35 @@
1
+ export const externalAuthFoundationMigration = {
2
+ id: '0013-external-auth-foundation',
3
+ statements: [
4
+ `CREATE TABLE IF NOT EXISTS yuncms_auth_identities (
5
+ id CHAR(36) NOT NULL PRIMARY KEY,
6
+ provider VARCHAR(64) NOT NULL,
7
+ subject VARCHAR(255) NOT NULL,
8
+ user CHAR(36) NOT NULL,
9
+ email VARCHAR(191) NULL,
10
+ profile JSON NULL,
11
+ last_login_at DATETIME(3) NULL,
12
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
13
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
14
+ UNIQUE KEY uq_yuncms_auth_identity_provider_subject (provider, subject),
15
+ KEY idx_yuncms_auth_identity_user (user),
16
+ CONSTRAINT fk_yuncms_auth_identity_user FOREIGN KEY (user)
17
+ REFERENCES yuncms_users (id) ON DELETE CASCADE
18
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
19
+
20
+ `CREATE TABLE IF NOT EXISTS yuncms_auth_transactions (
21
+ id CHAR(36) NOT NULL PRIMARY KEY,
22
+ provider VARCHAR(64) NOT NULL,
23
+ state_hash CHAR(64) NOT NULL,
24
+ secret_ciphertext TEXT NULL,
25
+ redirect_target VARCHAR(512) NULL,
26
+ metadata JSON NULL,
27
+ expires_at DATETIME(3) NOT NULL,
28
+ used_at DATETIME(3) NULL,
29
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
30
+ UNIQUE KEY uq_yuncms_auth_transaction_state (state_hash),
31
+ KEY idx_yuncms_auth_transaction_provider_expiry (provider, expires_at),
32
+ KEY idx_yuncms_auth_transaction_expiry (expires_at)
33
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
34
+ ],
35
+ };
package/src/migrations.js CHANGED
@@ -1,4 +1,10 @@
1
1
  const JOURNAL_TABLE = 'yuncms_schema_migrations';
2
+ const ATTEMPT_TABLE = 'yuncms_schema_migration_attempts';
3
+
4
+ function truncateErrorMessage(value, maxLength = 1000) {
5
+ const message = String(value ?? 'Migration failed');
6
+ return message.length > maxLength ? message.slice(0, maxLength) : message;
7
+ }
2
8
 
3
9
  export async function ensureMigrationJournal(database) {
4
10
  await database.query(`
@@ -7,6 +13,20 @@ export async function ensureMigrationJournal(database) {
7
13
  applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
8
14
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
9
15
  `);
16
+
17
+ await database.query(`
18
+ CREATE TABLE IF NOT EXISTS ${ATTEMPT_TABLE} (
19
+ migration_id VARCHAR(191) NOT NULL PRIMARY KEY,
20
+ status VARCHAR(16) NOT NULL,
21
+ statement_index INT UNSIGNED NOT NULL DEFAULT 0,
22
+ started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
23
+ finished_at DATETIME(3) NULL,
24
+ error_code VARCHAR(128) NULL,
25
+ error_message VARCHAR(1000) NULL,
26
+ CONSTRAINT chk_yuncms_schema_migration_attempt_status
27
+ CHECK (status IN ('applying', 'applied', 'failed'))
28
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
29
+ `);
10
30
  }
11
31
 
12
32
  export async function readAppliedMigrations(database) {
@@ -14,6 +34,15 @@ export async function readAppliedMigrations(database) {
14
34
  return new Set(rows.map((row) => row.id));
15
35
  }
16
36
 
37
+ export async function readMigrationAttempts(database) {
38
+ const [rows] = await database.query(
39
+ `SELECT migration_id, status, statement_index, started_at, finished_at, error_code, error_message
40
+ FROM ${ATTEMPT_TABLE}
41
+ ORDER BY started_at ASC, migration_id ASC`,
42
+ );
43
+ return rows;
44
+ }
45
+
17
46
  export function validateMigration(migration) {
18
47
  if (!migration || typeof migration !== 'object') throw new Error('Migration must be an object');
19
48
  if (!migration.id || typeof migration.id !== 'string') throw new Error('Migration id is required');
@@ -26,23 +55,102 @@ export function validateMigration(migration) {
26
55
  return migration;
27
56
  }
28
57
 
58
+ function migrationRecoveryError(attempts) {
59
+ const ids = attempts.map((attempt) => attempt.migration_id);
60
+ const error = new Error(
61
+ `Database contains an incomplete migration attempt and must be restored before retrying: ${ids.join(', ')}`,
62
+ );
63
+ error.code = 'DATABASE_MIGRATION_RECOVERY_REQUIRED';
64
+ error.migrationAttempts = attempts.map((attempt) => ({ ...attempt }));
65
+ return error;
66
+ }
67
+
68
+ async function assertNoIncompleteMigrationAttempts(database, applied, { allowMissingJournal = false } = {}) {
69
+ let attempts;
70
+ try {
71
+ attempts = await readMigrationAttempts(database);
72
+ } catch (error) {
73
+ if (allowMissingJournal && error?.code === 'ER_NO_SUCH_TABLE') return;
74
+ throw error;
75
+ }
76
+ const inconsistent = attempts.filter((attempt) => !applied.has(attempt.migration_id));
77
+ if (inconsistent.length > 0) throw migrationRecoveryError(inconsistent);
78
+ }
79
+
80
+ async function beginMigrationAttempt(database, migration) {
81
+ await database.query(
82
+ `INSERT INTO ${ATTEMPT_TABLE}
83
+ (migration_id, status, statement_index, started_at, finished_at, error_code, error_message)
84
+ VALUES (?, 'applying', 0, CURRENT_TIMESTAMP(3), NULL, NULL, NULL)`,
85
+ [migration.id],
86
+ );
87
+ }
88
+
89
+ async function advanceMigrationAttempt(database, migrationId, statementIndex) {
90
+ await database.query(
91
+ `UPDATE ${ATTEMPT_TABLE}
92
+ SET statement_index = ?
93
+ WHERE migration_id = ? AND status = 'applying'`,
94
+ [statementIndex, migrationId],
95
+ );
96
+ }
97
+
98
+ async function completeMigrationAttempt(database, migrationId) {
99
+ await database.query(
100
+ `UPDATE ${ATTEMPT_TABLE}
101
+ SET status = 'applied', finished_at = CURRENT_TIMESTAMP(3), error_code = NULL, error_message = NULL
102
+ WHERE migration_id = ?`,
103
+ [migrationId],
104
+ );
105
+ }
106
+
107
+ async function failMigrationAttempt(database, migrationId, error) {
108
+ await database.query(
109
+ `UPDATE ${ATTEMPT_TABLE}
110
+ SET status = 'failed', finished_at = CURRENT_TIMESTAMP(3), error_code = ?, error_message = ?
111
+ WHERE migration_id = ?`,
112
+ [
113
+ error?.code == null ? null : String(error.code).slice(0, 128),
114
+ truncateErrorMessage(error?.message),
115
+ migrationId,
116
+ ],
117
+ );
118
+ }
119
+
29
120
  export async function applyMigrations(database, migrations) {
30
121
  if (!database) throw new Error('Database handle is required');
31
122
  if (!Array.isArray(migrations)) throw new Error('Migrations must be an array');
32
123
 
33
124
  await ensureMigrationJournal(database);
34
125
  const applied = await readAppliedMigrations(database);
126
+ await assertNoIncompleteMigrationAttempts(database, applied);
35
127
  const newlyApplied = [];
36
128
 
37
129
  for (const rawMigration of migrations) {
38
130
  const migration = validateMigration(rawMigration);
39
131
  if (applied.has(migration.id)) continue;
40
132
 
41
- for (const statement of migration.statements) {
42
- await database.query(statement);
133
+ await beginMigrationAttempt(database, migration);
134
+
135
+ try {
136
+ for (let index = 0; index < migration.statements.length; index += 1) {
137
+ await database.query(migration.statements[index]);
138
+ await advanceMigrationAttempt(database, migration.id, index + 1);
139
+ }
140
+
141
+ await database.query(`INSERT INTO ${JOURNAL_TABLE} (id) VALUES (?)`, [migration.id]);
142
+ await completeMigrationAttempt(database, migration.id);
143
+ } catch (error) {
144
+ try {
145
+ await failMigrationAttempt(database, migration.id, error);
146
+ } catch {
147
+ // The original migration error remains the source of truth. A stale 'applying'
148
+ // row still fails closed on the next bootstrap and requires restore/recovery.
149
+ }
150
+ error.migrationId ||= migration.id;
151
+ throw error;
43
152
  }
44
153
 
45
- await database.query(`INSERT INTO ${JOURNAL_TABLE} (id) VALUES (?)`, [migration.id]);
46
154
  applied.add(migration.id);
47
155
  newlyApplied.push(migration.id);
48
156
  }
@@ -68,6 +176,8 @@ export async function assertMigrationsApplied(database, requiredMigrationIds) {
68
176
  throw error;
69
177
  }
70
178
 
179
+ await assertNoIncompleteMigrationAttempts(database, applied, { allowMissingJournal: true });
180
+
71
181
  const missing = requiredMigrationIds.filter((id) => !applied.has(id));
72
182
 
73
183
  if (missing.length > 0) {