@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.
@@ -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,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/query.js CHANGED
@@ -1,27 +1,34 @@
1
1
  import { quoteIdentifier } from './identifier.js';
2
2
 
3
- const QUERY_KEYS = new Set(['fields', 'filter', 'sort', 'limit', 'offset']);
3
+ const QUERY_KEYS = new Set(['fields', 'filter', 'sort', 'limit', 'offset', 'search', 'aggregate', 'groupBy']);
4
4
  const FILTER_OPERATORS = new Set([
5
5
  '_eq', '_neq', '_lt', '_lte', '_gt', '_gte',
6
6
  '_in', '_nin', '_null', '_nnull',
7
7
  '_contains', '_starts_with', '_ends_with',
8
8
  ]);
9
+ const AGGREGATE_FUNCTIONS = new Set(['count', 'countDistinct', 'sum', 'avg', 'min', 'max']);
10
+ const SEARCHABLE_TYPES = new Set(['string', 'text']);
9
11
 
10
12
  export const QUERY_LIMITS = Object.freeze({
11
13
  defaultLimit: 100,
12
14
  maxLimit: 500,
13
15
  maxFields: 100,
14
16
  maxRelationExpansions: 20,
17
+ maxRelationDepth: 4,
15
18
  maxSortFields: 20,
16
19
  maxOffset: 1_000_000,
17
20
  maxFilterDepth: 8,
18
21
  maxFilterNodes: 100,
19
22
  maxInValues: 100,
23
+ maxSearchLength: 200,
24
+ maxAggregateFields: 20,
25
+ maxGroupByFields: 10,
26
+ maxCost: 2_000,
20
27
  });
21
28
 
22
- function queryError(message, path = null) {
29
+ function queryError(message, path = null, code = 'INVALID_QUERY') {
23
30
  const error = new Error(message);
24
- error.code = 'INVALID_QUERY';
31
+ error.code = code;
25
32
  if (path) error.path = path;
26
33
  return error;
27
34
  }
@@ -51,45 +58,92 @@ function normalizeFilter(value) {
51
58
  if (typeof value === 'string') {
52
59
  try {
53
60
  const parsed = JSON.parse(value);
54
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
55
- throw new Error('not an object');
56
- }
61
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
57
62
  return parsed;
58
63
  } catch {
59
64
  throw queryError('filter must be a valid JSON object', 'filter');
60
65
  }
61
66
  }
62
- if (typeof value !== 'object' || Array.isArray(value)) {
63
- throw queryError('filter must be an object', 'filter');
64
- }
67
+ if (typeof value !== 'object' || Array.isArray(value)) throw queryError('filter must be an object', 'filter');
65
68
  return value;
66
69
  }
67
70
 
71
+ function normalizeSearch(value, limits) {
72
+ if (value == null || value === '') return null;
73
+ if (Array.isArray(value) || typeof value === 'object') throw queryError('search must be a string', 'search');
74
+ const search = String(value).trim();
75
+ if (!search) return null;
76
+ if (search.length > limits.maxSearchLength) {
77
+ throw queryError(`search cannot exceed ${limits.maxSearchLength} characters`, 'search');
78
+ }
79
+ return search;
80
+ }
81
+
82
+ function normalizeAggregate(value, limits) {
83
+ if (value == null || value === '') return null;
84
+ let aggregate = value;
85
+ if (typeof aggregate === 'string') {
86
+ try { aggregate = JSON.parse(aggregate); } catch { throw queryError('aggregate must be a JSON object', 'aggregate'); }
87
+ }
88
+ if (!aggregate || typeof aggregate !== 'object' || Array.isArray(aggregate)) {
89
+ throw queryError('aggregate must be an object', 'aggregate');
90
+ }
91
+
92
+ const normalized = {};
93
+ let count = 0;
94
+ for (const [fn, rawFields] of Object.entries(aggregate)) {
95
+ if (!AGGREGATE_FUNCTIONS.has(fn)) throw queryError(`Unknown aggregate function: ${fn}`, `aggregate.${fn}`);
96
+ const fields = normalizeDelimited(rawFields, `aggregate.${fn}`, { maxItems: limits.maxAggregateFields });
97
+ if (!fields) throw queryError(`aggregate.${fn} cannot be empty`, `aggregate.${fn}`);
98
+ count += fields.length;
99
+ if (count > limits.maxAggregateFields) {
100
+ throw queryError(`aggregate cannot contain more than ${limits.maxAggregateFields} fields`, 'aggregate');
101
+ }
102
+ normalized[fn] = fields;
103
+ }
104
+ if (Object.keys(normalized).length === 0) throw queryError('aggregate cannot be empty', 'aggregate');
105
+ return normalized;
106
+ }
107
+
68
108
  export function parseItemsQuery(raw = {}, options = {}) {
69
109
  const limits = { ...QUERY_LIMITS, ...options };
70
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
71
- throw queryError('Query must be an object');
72
- }
110
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw queryError('Query must be an object');
73
111
 
74
112
  for (const key of Object.keys(raw)) {
75
113
  if (!QUERY_KEYS.has(key)) throw queryError(`Unknown query parameter: ${key}`, key);
76
114
  }
77
115
 
78
- return {
116
+ const query = {
79
117
  fields: normalizeDelimited(raw.fields, 'fields', { maxItems: limits.maxFields }),
80
118
  filter: normalizeFilter(raw.filter),
81
119
  sort: normalizeDelimited(raw.sort, 'sort', { maxItems: limits.maxSortFields }),
82
- limit: normalizeInteger(raw.limit, limits.defaultLimit, {
83
- label: 'limit',
84
- min: 1,
85
- max: limits.maxLimit,
86
- }),
87
- offset: normalizeInteger(raw.offset, 0, {
88
- label: 'offset',
89
- min: 0,
90
- max: limits.maxOffset,
91
- }),
120
+ limit: normalizeInteger(raw.limit, limits.defaultLimit, { label: 'limit', min: 1, max: limits.maxLimit }),
121
+ offset: normalizeInteger(raw.offset, 0, { label: 'offset', min: 0, max: limits.maxOffset }),
122
+ search: normalizeSearch(raw.search, limits),
123
+ aggregate: normalizeAggregate(raw.aggregate, limits),
124
+ groupBy: normalizeDelimited(raw.groupBy, 'groupBy', { maxItems: limits.maxGroupByFields }),
92
125
  };
126
+
127
+ if (query.groupBy && !query.aggregate) throw queryError('groupBy requires aggregate', 'groupBy');
128
+ return Object.freeze(query);
129
+ }
130
+
131
+ export function queryCost(query, { relationDepth = 0, relationCount = 0 } = {}) {
132
+ let cost = 1 + Number(query?.limit ?? QUERY_LIMITS.defaultLimit);
133
+ cost += (query?.fields?.length ?? 0) * 2;
134
+ cost += (query?.sort?.length ?? 0) * 5;
135
+ cost += relationCount * 50;
136
+ cost += relationDepth * 100;
137
+ if (query?.search) cost += 100;
138
+ if (query?.aggregate) cost += 250 + Object.values(query.aggregate).flat().length * 25;
139
+ return cost;
140
+ }
141
+
142
+ export function assertQueryCost(query, options = {}) {
143
+ const maxCost = options.maxCost ?? QUERY_LIMITS.maxCost;
144
+ const cost = queryCost(query, options);
145
+ if (cost > maxCost) throw queryError(`Query cost ${cost} exceeds limit ${maxCost}`, null, 'QUERY_COST_LIMIT');
146
+ return cost;
93
147
  }
94
148
 
95
149
  function resolveField(schema, field, path = field) {
@@ -100,7 +154,6 @@ function resolveField(schema, field, path = field) {
100
154
  export function compileSelectFields(fields, schema) {
101
155
  const selected = !fields || fields.includes('*') ? Object.keys(schema.fields) : fields;
102
156
  if (selected.length === 0) throw queryError('At least one field must be selected', 'fields');
103
-
104
157
  const unique = [...new Set(selected)];
105
158
  return {
106
159
  fields: unique,
@@ -113,7 +166,6 @@ export function compileSelectFields(fields, schema) {
113
166
 
114
167
  export function compileSort(sort, schema) {
115
168
  if (!sort) return '';
116
-
117
169
  const parts = sort.map((entry, index) => {
118
170
  const descending = entry.startsWith('-');
119
171
  const field = descending ? entry.slice(1) : entry;
@@ -121,7 +173,6 @@ export function compileSort(sort, schema) {
121
173
  resolveField(schema, field, `sort.${index}`);
122
174
  return `${quoteIdentifier(field, 'field name')} ${descending ? 'DESC' : 'ASC'}`;
123
175
  });
124
-
125
176
  return parts.length ? ` ORDER BY ${parts.join(', ')}` : '';
126
177
  }
127
178
 
@@ -129,9 +180,49 @@ function escapeLike(value) {
129
180
  return String(value).replace(/[\\%_]/g, '\\$&');
130
181
  }
131
182
 
183
+ export function compileSearch(search, schema) {
184
+ if (!search) return { sql: '', params: [] };
185
+ const fields = Object.entries(schema?.fields ?? {})
186
+ .filter(([, field]) => SEARCHABLE_TYPES.has(field.type))
187
+ .map(([name]) => name);
188
+ if (fields.length === 0) return { sql: ' WHERE 0 = 1', params: [] };
189
+ const needle = `%${escapeLike(search)}%`;
190
+ return {
191
+ sql: ` WHERE (${fields.map((field) => `${quoteIdentifier(field, 'field name')} LIKE ? ESCAPE '\\\\'`).join(' OR ')})`,
192
+ params: fields.map(() => needle),
193
+ };
194
+ }
195
+
196
+ export function compileAggregate(aggregate, groupBy, schema) {
197
+ if (!aggregate) return null;
198
+ const groups = groupBy ?? [];
199
+ for (const field of groups) resolveField(schema, field, `groupBy.${field}`);
200
+ const selections = groups.map((field) => quoteIdentifier(field, 'field name'));
201
+ const aliases = new Set(groups);
202
+
203
+ for (const [fn, fields] of Object.entries(aggregate)) {
204
+ for (const field of fields) {
205
+ if (field === '*' && fn !== 'count') throw queryError(`${fn} does not support *`, `aggregate.${fn}`);
206
+ if (field !== '*') resolveField(schema, field, `aggregate.${fn}.${field}`);
207
+ const column = field === '*' ? '*' : quoteIdentifier(field, 'field name');
208
+ const sqlFn = fn === 'countDistinct' ? 'COUNT' : fn.toUpperCase();
209
+ const expression = fn === 'countDistinct' ? `${sqlFn}(DISTINCT ${column})` : `${sqlFn}(${column})`;
210
+ const rawAlias = field === '*' ? fn : `${fn}_${field}`;
211
+ let alias = rawAlias;
212
+ let suffix = 2;
213
+ while (aliases.has(alias)) alias = `${rawAlias}_${suffix++}`;
214
+ aliases.add(alias);
215
+ selections.push(`${expression} AS ${quoteIdentifier(alias, 'aggregate alias')}`);
216
+ }
217
+ }
218
+ return {
219
+ sql: selections.join(', '),
220
+ groupSql: groups.length ? ` GROUP BY ${groups.map((field) => quoteIdentifier(field, 'field name')).join(', ')}` : '',
221
+ };
222
+ }
223
+
132
224
  function compileOperator(fieldSql, operator, value, path, limits) {
133
225
  if (!FILTER_OPERATORS.has(operator)) throw queryError(`Unknown filter operator: ${operator}`, path);
134
-
135
226
  switch (operator) {
136
227
  case '_eq':
137
228
  if (value === null) throw queryError('Use _null for NULL comparisons', path);
@@ -146,15 +237,10 @@ function compileOperator(fieldSql, operator, value, path, limits) {
146
237
  case '_in':
147
238
  case '_nin': {
148
239
  if (!Array.isArray(value)) throw queryError(`${operator} requires an array`, path);
149
- if (value.length > limits.maxInValues) {
150
- throw queryError(`${operator} accepts at most ${limits.maxInValues} values`, path);
151
- }
240
+ if (value.length > limits.maxInValues) throw queryError(`${operator} accepts at most ${limits.maxInValues} values`, path);
152
241
  if (value.length === 0) return { sql: operator === '_in' ? '0 = 1' : '1 = 1', params: [] };
153
242
  const placeholders = value.map(() => '?').join(', ');
154
- return {
155
- sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`,
156
- params: value,
157
- };
243
+ return { sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`, params: value };
158
244
  }
159
245
  case '_null':
160
246
  case '_nnull': {
@@ -162,67 +248,41 @@ function compileOperator(fieldSql, operator, value, path, limits) {
162
248
  const wantsNull = operator === '_null' ? value : !value;
163
249
  return { sql: `${fieldSql} IS ${wantsNull ? '' : 'NOT '}NULL`, params: [] };
164
250
  }
165
- case '_contains':
166
- return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}%`] };
167
- case '_starts_with':
168
- return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`${escapeLike(value)}%`] };
169
- case '_ends_with':
170
- return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}`] };
171
- default:
172
- throw queryError(`Unknown filter operator: ${operator}`, path);
251
+ case '_contains': return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}%`] };
252
+ case '_starts_with': return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`${escapeLike(value)}%`] };
253
+ case '_ends_with': return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}`] };
254
+ default: throw queryError(`Unknown filter operator: ${operator}`, path);
173
255
  }
174
256
  }
175
257
 
176
258
  function compileFilterObject(filter, schema, path, limits, state, depth) {
177
- if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
178
- throw queryError('Filter node must be an object', path);
179
- }
180
- if (depth > limits.maxFilterDepth) {
181
- throw queryError(`Filter depth cannot exceed ${limits.maxFilterDepth}`, path);
182
- }
183
-
259
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter)) throw queryError('Filter node must be an object', path);
260
+ if (depth > limits.maxFilterDepth) throw queryError(`Filter depth cannot exceed ${limits.maxFilterDepth}`, path);
184
261
  state.nodes += 1;
185
- if (state.nodes > limits.maxFilterNodes) {
186
- throw queryError(`Filter cannot contain more than ${limits.maxFilterNodes} nodes`, path);
187
- }
262
+ if (state.nodes > limits.maxFilterNodes) throw queryError(`Filter cannot contain more than ${limits.maxFilterNodes} nodes`, path);
188
263
 
189
264
  const fragments = [];
190
265
  const params = [];
191
-
192
266
  for (const [key, value] of Object.entries(filter)) {
193
267
  if (key === '_and' || key === '_or') {
194
- if (!Array.isArray(value) || value.length === 0) {
195
- throw queryError(`${key} requires a non-empty array`, `${path}.${key}`);
196
- }
197
- const children = value.map((child, index) =>
198
- compileFilterObject(child, schema, `${path}.${key}.${index}`, limits, state, depth + 1));
268
+ if (!Array.isArray(value) || value.length === 0) throw queryError(`${key} requires a non-empty array`, `${path}.${key}`);
269
+ const children = value.map((child, index) => compileFilterObject(child, schema, `${path}.${key}.${index}`, limits, state, depth + 1));
199
270
  fragments.push(`(${children.map((child) => child.sql).join(key === '_and' ? ' AND ' : ' OR ')})`);
200
271
  for (const child of children) params.push(...child.params);
201
272
  continue;
202
273
  }
203
-
204
274
  resolveField(schema, key, `${path}.${key}`);
205
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
206
- throw queryError('Field filters must be operator objects', `${path}.${key}`);
207
- }
208
-
275
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw queryError('Field filters must be operator objects', `${path}.${key}`);
209
276
  const fieldSql = quoteIdentifier(key, 'field name');
210
277
  const fieldFragments = [];
211
278
  for (const [operator, operatorValue] of Object.entries(value)) {
212
- const compiled = compileOperator(
213
- fieldSql,
214
- operator,
215
- operatorValue,
216
- `${path}.${key}.${operator}`,
217
- limits,
218
- );
279
+ const compiled = compileOperator(fieldSql, operator, operatorValue, `${path}.${key}.${operator}`, limits);
219
280
  fieldFragments.push(compiled.sql);
220
281
  params.push(...compiled.params);
221
282
  }
222
283
  if (fieldFragments.length === 0) throw queryError('Field filter cannot be empty', `${path}.${key}`);
223
284
  fragments.push(`(${fieldFragments.join(' AND ')})`);
224
285
  }
225
-
226
286
  if (fragments.length === 0) throw queryError('Filter cannot be empty', path);
227
287
  return { sql: fragments.join(' AND '), params };
228
288
  }