@yunsoft/yuncms-core 0.1.3 → 0.1.5

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.
@@ -1,8 +1,9 @@
1
1
  import { assertIdentifier } from './identifier.js';
2
+ import { QUERY_LIMITS } from './query.js';
2
3
  import { SchemaCache } from './schema.js';
3
4
  import { ItemsService } from './services/items-service.js';
4
5
 
5
- const MAX_EXPAND_FIELDS = 8;
6
+ export const MAX_EXPAND_FIELDS = QUERY_LIMITS.maxRelationExpansions;
6
7
  const defaultSchemaCache = new SchemaCache();
7
8
 
8
9
  function expansionError(code, message, path = null) {
@@ -18,16 +19,24 @@ function normalizeDelimited(value) {
18
19
  return values.map((entry) => String(entry).trim()).filter(Boolean);
19
20
  }
20
21
 
22
+ function assertFieldToken(field, path) {
23
+ try {
24
+ assertIdentifier(field, 'field');
25
+ } catch {
26
+ throw expansionError('INVALID_QUERY', `Invalid field: ${field}`, path);
27
+ }
28
+ return field;
29
+ }
30
+
21
31
  export function parseExpandInput(value) {
22
32
  const fields = [...new Set(normalizeDelimited(value))];
23
33
  if (fields.length > MAX_EXPAND_FIELDS) {
24
34
  throw expansionError(
25
35
  'INVALID_QUERY',
26
- `expand supports at most ${MAX_EXPAND_FIELDS} direct relation fields`,
36
+ `expand cannot contain more than ${MAX_EXPAND_FIELDS} entries`,
27
37
  'expand',
28
38
  );
29
39
  }
30
-
31
40
  for (const field of fields) {
32
41
  try {
33
42
  assertIdentifier(field, 'expand field');
@@ -47,73 +56,199 @@ function parseRelationMetadata(value) {
47
56
  }
48
57
  }
49
58
 
50
- function withExpansionFields(rawFields, expandFields) {
51
- if (rawFields == null || rawFields === '') return rawFields;
52
- const selected = normalizeDelimited(rawFields);
53
- if (selected.includes('*')) return selected;
54
- return [...new Set([...selected, ...expandFields])];
55
- }
56
-
57
- function withoutExpand(query = {}, expandFields = []) {
58
- const base = { ...query };
59
- delete base.expand;
60
- if (expandFields.length > 0 && Object.hasOwn(base, 'fields')) {
61
- base.fields = withExpansionFields(base.fields, expandFields);
62
- }
63
- return base;
64
- }
65
-
66
59
  async function schemaSnapshot(options) {
67
60
  if (options.schema) return options.schema;
68
61
  const cache = options.schemaCache ?? defaultSchemaCache;
69
62
  return cache.get(options.database);
70
63
  }
71
64
 
72
- function directRelation(snapshot, collection, field) {
73
- const relation = snapshot.relationByManyField?.get(`${collection}.${field}`);
65
+ function relationFromSnapshot(snapshot, collection, field) {
66
+ return snapshot.relationByManyField?.get(`${collection}.${field}`) ?? null;
67
+ }
68
+
69
+ function isDirectRelation(relation) {
70
+ if (!relation) return false;
71
+ const metadata = parseRelationMetadata(relation.metadata);
72
+ return !relation.junction_collection && metadata.kind !== 'm2m';
73
+ }
74
+
75
+ function directRelation(snapshot, collection, field, path = `fields.${field}`) {
76
+ const relation = relationFromSnapshot(snapshot, collection, field);
74
77
  if (!relation) {
75
78
  throw expansionError(
76
79
  'INVALID_QUERY',
77
80
  `Field is not a direct relation and cannot be expanded: ${collection}.${field}`,
78
- `expand.${field}`,
81
+ path,
79
82
  );
80
83
  }
81
84
 
82
- const metadata = parseRelationMetadata(relation.metadata);
83
- if (relation.junction_collection || metadata.kind === 'm2m') {
85
+ if (!isDirectRelation(relation)) {
84
86
  throw expansionError(
85
87
  'UNSUPPORTED_RELATION_EXPANSION',
86
- `Only direct M2O fields can be expanded in V1: ${collection}.${field}`,
87
- `expand.${field}`,
88
+ `Only direct M2O/O2O fields can be expanded: ${collection}.${field}`,
89
+ path,
88
90
  );
89
91
  }
90
92
  return relation;
91
93
  }
92
94
 
93
- async function validateSourceExpansions({ collection, expandFields, options, service }) {
95
+ function readableSourceField(sourceSchema, permission, field) {
96
+ return Boolean(sourceSchema.fields?.[field]
97
+ && (!permission.fields || permission.fields.includes(field)));
98
+ }
99
+
100
+ function directReadableRelationFields(snapshot, collection, sourceSchema, permission) {
101
+ const prefix = `${collection}.`;
102
+ const fields = [];
103
+ for (const [key, relation] of snapshot.relationByManyField?.entries?.() ?? []) {
104
+ if (!key.startsWith(prefix) || !isDirectRelation(relation)) continue;
105
+ const field = key.slice(prefix.length);
106
+ if (readableSourceField(sourceSchema, permission, field)) fields.push(field);
107
+ }
108
+ return [...new Set(fields)];
109
+ }
110
+
111
+ function mergeExpansionSelection(expansions, relationField, targetField) {
112
+ const current = expansions.get(relationField) ?? [];
113
+ if (current.includes('*')) return;
114
+ if (targetField === '*') {
115
+ expansions.set(relationField, ['*']);
116
+ return;
117
+ }
118
+ expansions.set(relationField, [...new Set([...current, targetField])]);
119
+ }
120
+
121
+ function assertExpansionLimit(expansions, path = 'fields') {
122
+ if (expansions.size > MAX_EXPAND_FIELDS) {
123
+ throw expansionError(
124
+ 'INVALID_QUERY',
125
+ `Relation expansion cannot contain more than ${MAX_EXPAND_FIELDS} fields`,
126
+ path,
127
+ );
128
+ }
129
+ }
130
+
131
+ function parseFieldsPlan({ value, snapshot, collection, sourceSchema, permission }) {
132
+ const tokens = normalizeDelimited(value);
133
+ if (tokens.length === 0) {
134
+ return { sourceFields: null, expansions: new Map() };
135
+ }
136
+
137
+ let sourceAll = false;
138
+ const sourceFields = [];
139
+ const expansions = new Map();
140
+
141
+ for (const token of tokens) {
142
+ if (token === '*') {
143
+ sourceAll = true;
144
+ continue;
145
+ }
146
+
147
+ if (token === '*.*') {
148
+ sourceAll = true;
149
+ for (const relationField of directReadableRelationFields(
150
+ snapshot,
151
+ collection,
152
+ sourceSchema,
153
+ permission,
154
+ )) {
155
+ mergeExpansionSelection(expansions, relationField, '*');
156
+ }
157
+ assertExpansionLimit(expansions);
158
+ continue;
159
+ }
160
+
161
+ if (!token.includes('.')) {
162
+ sourceFields.push(assertFieldToken(token, `fields.${token}`));
163
+ continue;
164
+ }
165
+
166
+ const parts = token.split('.');
167
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
168
+ throw expansionError(
169
+ 'UNSUPPORTED_RELATION_EXPANSION',
170
+ `Only one relation depth is supported in fields: ${token}`,
171
+ `fields.${token}`,
172
+ );
173
+ }
174
+
175
+ const relationField = assertFieldToken(parts[0], `fields.${token}`);
176
+ const targetField = parts[1] === '*'
177
+ ? '*'
178
+ : assertFieldToken(parts[1], `fields.${token}`);
179
+
180
+ if (!readableSourceField(sourceSchema, permission, relationField)) {
181
+ throw expansionError('INVALID_QUERY', `Unknown field: ${relationField}`, `fields.${token}`);
182
+ }
183
+ directRelation(snapshot, collection, relationField, `fields.${token}`);
184
+ sourceFields.push(relationField);
185
+ mergeExpansionSelection(expansions, relationField, targetField);
186
+ assertExpansionLimit(expansions);
187
+ }
188
+
189
+ return {
190
+ sourceFields: sourceAll ? ['*'] : [...new Set(sourceFields)],
191
+ expansions,
192
+ };
193
+ }
194
+
195
+ function addLegacyExpansions({ query, plan, snapshot, collection, sourceSchema, permission }) {
196
+ for (const field of parseExpandInput(query.expand)) {
197
+ if (!readableSourceField(sourceSchema, permission, field)) {
198
+ throw expansionError('INVALID_QUERY', `Unknown field: ${field}`, `expand.${field}`);
199
+ }
200
+ directRelation(snapshot, collection, field, `expand.${field}`);
201
+ mergeExpansionSelection(plan.expansions, field, '*');
202
+ assertExpansionLimit(plan.expansions, 'expand');
203
+ if (plan.sourceFields && !plan.sourceFields.includes('*') && !plan.sourceFields.includes(field)) {
204
+ plan.sourceFields.push(field);
205
+ }
206
+ }
207
+ return plan;
208
+ }
209
+
210
+ async function buildSelectionPlan({ collection, query, options, service }) {
94
211
  const snapshot = await schemaSnapshot(options);
95
212
  const sourceSchema = snapshot.collections?.[collection];
96
213
  if (!sourceSchema) {
97
214
  throw expansionError('COLLECTION_NOT_FOUND', `Unknown collection: ${collection}`);
98
215
  }
99
- if (expandFields.length === 0) return snapshot;
100
216
 
101
217
  const permission = await service.resolvePermission('read');
102
- for (const field of expandFields) {
103
- if (!sourceSchema.fields?.[field] || (permission.fields && !permission.fields.includes(field))) {
104
- throw expansionError('INVALID_QUERY', `Unknown field: ${field}`, `expand.${field}`);
105
- }
106
- directRelation(snapshot, collection, field);
107
- }
108
- return snapshot;
218
+ const plan = parseFieldsPlan({
219
+ value: query.fields,
220
+ snapshot,
221
+ collection,
222
+ sourceSchema,
223
+ permission,
224
+ });
225
+ addLegacyExpansions({ query, plan, snapshot, collection, sourceSchema, permission });
226
+ return { ...plan, snapshot };
109
227
  }
110
228
 
111
- async function expandRows({ collection, rows, expandFields, options, ItemsServiceClass, snapshot }) {
112
- if (expandFields.length === 0 || rows.length === 0) return rows;
229
+ function baseQuery(query, sourceFields) {
230
+ const base = { ...query };
231
+ delete base.expand;
232
+ if (sourceFields == null) delete base.fields;
233
+ else base.fields = sourceFields;
234
+ return base;
235
+ }
236
+
237
+ function projectTarget(target, selection, visibleFields) {
238
+ const projectedFields = selection.includes('*') ? visibleFields : selection;
239
+ return Object.fromEntries(
240
+ projectedFields
241
+ .filter((field) => Object.hasOwn(target, field))
242
+ .map((field) => [field, target[field]]),
243
+ );
244
+ }
245
+
246
+ async function expandRows({ collection, rows, expansions, options, ItemsServiceClass, snapshot }) {
247
+ if (expansions.size === 0 || rows.length === 0) return rows;
113
248
  const effectiveSnapshot = snapshot ?? await schemaSnapshot(options);
114
249
  let expandedRows = rows.map((row) => ({ ...row }));
115
250
 
116
- for (const field of expandFields) {
251
+ for (const [field, selection] of expansions) {
117
252
  const relation = directRelation(effectiveSnapshot, collection, field);
118
253
  const targetCollection = relation.one_collection;
119
254
  const targetKey = relation.one_field
@@ -132,21 +267,25 @@ async function expandRows({ collection, rows, expandFields, options, ItemsServic
132
267
  }
133
268
 
134
269
  const targetService = new ItemsServiceClass(targetCollection, options);
135
- const targetRows = await targetService.readMany({
136
- filter: { [targetKey]: { _in: values } },
137
- limit: Math.min(values.length, 500),
270
+ const targetResult = await targetService.readManyForRelation({
271
+ fields: selection,
272
+ lookupField: targetKey,
273
+ values,
138
274
  });
139
275
 
140
276
  const byKey = new Map();
141
- for (const target of targetRows) {
277
+ for (const target of targetResult.data) {
142
278
  if (!Object.hasOwn(target, targetKey)) {
143
279
  throw expansionError(
144
280
  'FORBIDDEN_FIELD',
145
281
  `Expanded relation key is not readable: ${targetCollection}.${targetKey}`,
146
- `expand.${field}`,
282
+ `fields.${field}`,
147
283
  );
148
284
  }
149
- byKey.set(String(target[targetKey]), target);
285
+ byKey.set(
286
+ String(target[targetKey]),
287
+ projectTarget(target, selection, targetResult.visibleFields),
288
+ );
150
289
  }
151
290
 
152
291
  expandedRows = expandedRows.map((row) => ({
@@ -167,17 +306,16 @@ export async function readManyWithRelations({
167
306
  ItemsServiceClass = ItemsService,
168
307
  } = {}) {
169
308
  assertIdentifier(collection, 'collection name');
170
- const expandFields = parseExpandInput(query.expand);
171
309
  const service = new ItemsServiceClass(collection, options);
172
- const snapshot = await validateSourceExpansions({ collection, expandFields, options, service });
173
- const result = await service.readManyWithMeta(withoutExpand(query, expandFields));
310
+ const plan = await buildSelectionPlan({ collection, query, options, service });
311
+ const result = await service.readManyWithMeta(baseQuery(query, plan.sourceFields));
174
312
  const data = await expandRows({
175
313
  collection,
176
314
  rows: result.data,
177
- expandFields,
315
+ expansions: plan.expansions,
178
316
  options,
179
317
  ItemsServiceClass,
180
- snapshot,
318
+ snapshot: plan.snapshot,
181
319
  });
182
320
  return { ...result, data };
183
321
  }
@@ -196,21 +334,17 @@ export async function readOneWithRelations({
196
334
  }
197
335
  }
198
336
 
199
- const expandFields = parseExpandInput(query.expand);
200
337
  const service = new ItemsServiceClass(collection, options);
201
- const snapshot = await validateSourceExpansions({ collection, expandFields, options, service });
202
- const fields = withExpansionFields(query.fields ?? null, expandFields);
203
- const record = await service.readOne(id, { fields });
338
+ const plan = await buildSelectionPlan({ collection, query, options, service });
339
+ const record = await service.readOne(id, { fields: plan.sourceFields });
204
340
  if (!record) return null;
205
341
  const [expanded] = await expandRows({
206
342
  collection,
207
343
  rows: [record],
208
- expandFields,
344
+ expansions: plan.expansions,
209
345
  options,
210
346
  ItemsServiceClass,
211
- snapshot,
347
+ snapshot: plan.snapshot,
212
348
  });
213
349
  return expanded;
214
350
  }
215
-
216
- export { MAX_EXPAND_FIELDS };
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
+ import { compileFilter } from '../query.js';
3
4
  import { BaseService } from './base-service.js';
4
5
  import { resolveSystemResourceAccess } from './system-resource-access.js';
5
6
 
@@ -27,6 +28,38 @@ function normalizeMimeType(value) {
27
28
  return mimetype;
28
29
  }
29
30
 
31
+ function startsWithBytes(buffer, bytes, offset = 0) {
32
+ if (buffer.byteLength < offset + bytes.length) return false;
33
+ return bytes.every((byte, index) => buffer[offset + index] === byte);
34
+ }
35
+
36
+ function hasKnownMimeSignature(contents, mimetype) {
37
+ const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
38
+ switch (mimetype) {
39
+ case 'application/pdf':
40
+ return startsWithBytes(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d]);
41
+ case 'image/png':
42
+ return startsWithBytes(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
43
+ case 'image/jpeg':
44
+ return startsWithBytes(buffer, [0xff, 0xd8, 0xff]);
45
+ case 'image/gif':
46
+ return buffer.subarray(0, 6).toString('ascii') === 'GIF87a'
47
+ || buffer.subarray(0, 6).toString('ascii') === 'GIF89a';
48
+ case 'image/webp':
49
+ return buffer.subarray(0, 4).toString('ascii') === 'RIFF'
50
+ && buffer.subarray(8, 12).toString('ascii') === 'WEBP';
51
+ default:
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function assertMimeSignature(contents, mimetype) {
57
+ const matches = hasKnownMimeSignature(contents, mimetype);
58
+ if (matches === false) {
59
+ throw fileError('FILE_MIME_MISMATCH', `File contents do not match declared MIME type: ${mimetype}`);
60
+ }
61
+ }
62
+
30
63
  function decodeJson(value) {
31
64
  if (value == null || typeof value === 'object') return value ?? null;
32
65
  try {
@@ -56,6 +89,31 @@ export class FilesService extends BaseService {
56
89
  });
57
90
  }
58
91
 
92
+ #compileReadScope(permission, id = null) {
93
+ let sql = '';
94
+ let params = [];
95
+
96
+ if (permission?.filter) {
97
+ const collectionSchema = this.schema?.collections?.yuncms_files;
98
+ if (!collectionSchema) {
99
+ throw fileError(
100
+ 'SYSTEM_SCHEMA_REQUIRED',
101
+ 'System schema is required to enforce a filtered Files permission',
102
+ );
103
+ }
104
+ const compiled = compileFilter(permission.filter, collectionSchema);
105
+ sql = compiled.sql;
106
+ params = [...compiled.params];
107
+ }
108
+
109
+ if (id != null) {
110
+ sql = sql ? `${sql} AND id = ?` : ' WHERE id = ?';
111
+ params.push(id);
112
+ }
113
+
114
+ return { sql, params };
115
+ }
116
+
59
117
  async #readOneUnsafe(id) {
60
118
  const [rows] = await this.database.query(
61
119
  `SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
@@ -68,20 +126,34 @@ export class FilesService extends BaseService {
68
126
  return normalizeRow(rows[0]);
69
127
  }
70
128
 
129
+ async #readOneAuthorized(id, permission) {
130
+ const scope = this.#compileReadScope(permission, id);
131
+ const [rows] = await this.database.query(
132
+ `SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
133
+ uploaded_by, uploaded_at, metadata
134
+ FROM yuncms_files${scope.sql}
135
+ LIMIT 1`,
136
+ scope.params,
137
+ );
138
+ return normalizeRow(rows[0]);
139
+ }
140
+
71
141
  async readMany() {
72
- await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
142
+ const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
143
+ const scope = this.#compileReadScope(permission);
73
144
  const [rows] = await this.database.query(
74
145
  `SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
75
146
  uploaded_by, uploaded_at, metadata
76
- FROM yuncms_files
147
+ FROM yuncms_files${scope.sql}
77
148
  ORDER BY uploaded_at DESC, id DESC`,
149
+ scope.params,
78
150
  );
79
151
  return rows.map(normalizeRow);
80
152
  }
81
153
 
82
154
  async readOne(id) {
83
- await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
84
- return this.#readOneUnsafe(id);
155
+ const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
156
+ return this.#readOneAuthorized(id, permission);
85
157
  }
86
158
 
87
159
  async createOne({
@@ -100,6 +172,7 @@ export class FilesService extends BaseService {
100
172
 
101
173
  const filename = normalizeFilename(filenameDownload);
102
174
  const normalizedMime = normalizeMimeType(mimetype);
175
+ assertMimeSignature(contents, normalizedMime);
103
176
  const driver = this.storage.get(storage);
104
177
  const id = randomUUID();
105
178
  const filenameDisk = id;
@@ -144,8 +217,8 @@ export class FilesService extends BaseService {
144
217
  }
145
218
 
146
219
  async readContent(id) {
147
- await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
148
- const file = await this.#readOneUnsafe(id);
220
+ const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
221
+ const file = await this.#readOneAuthorized(id, permission);
149
222
  if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
150
223
  const driver = this.storage.get(file.storage);
151
224
  const contents = await driver.get(file.filename_disk);
@@ -225,4 +298,9 @@ export class FilesService extends BaseService {
225
298
  }
226
299
  }
227
300
 
228
- export { normalizeFilename, normalizeMimeType };
301
+ export {
302
+ assertMimeSignature,
303
+ hasKnownMimeSignature,
304
+ normalizeFilename,
305
+ normalizeMimeType,
306
+ };
@@ -7,6 +7,7 @@ import {
7
7
  compileSelectFields,
8
8
  compileSort,
9
9
  parseItemsQuery,
10
+ QUERY_LIMITS,
10
11
  } from '../query.js';
11
12
  import { SchemaCache } from '../schema.js';
12
13
  import { isSystemManagedField, systemMutationEntries } from '../system-fields.js';
@@ -215,6 +216,58 @@ export class ItemsService extends BaseService {
215
216
  };
216
217
  }
217
218
 
219
+ async readManyForRelation({ fields = null, lookupField, values = [] } = {}) {
220
+ const schema = await this.getCollectionSchema();
221
+ const trustedLookupField = assertIdentifier(lookupField, 'relation lookup field');
222
+ if (!schema.fields[trustedLookupField]) {
223
+ throw serviceError(
224
+ 'INVALID_QUERY',
225
+ `Unknown relation lookup field: ${trustedLookupField}`,
226
+ trustedLookupField,
227
+ );
228
+ }
229
+ if (!Array.isArray(values) || values.length === 0 || values.length > QUERY_LIMITS.maxLimit) {
230
+ throw serviceError(
231
+ 'INVALID_QUERY',
232
+ `Relation lookup values must contain between 1 and ${QUERY_LIMITS.maxLimit} entries`,
233
+ trustedLookupField,
234
+ );
235
+ }
236
+
237
+ const permission = await this.resolvePermission('read');
238
+ const accessSchema = schemaForFields(schema, permission.fields);
239
+ const visibleSelection = compileSelectFields(normalizeFields(fields), accessSchema);
240
+ const internalSchema = {
241
+ ...accessSchema,
242
+ fields: {
243
+ ...accessSchema.fields,
244
+ [trustedLookupField]: schema.fields[trustedLookupField],
245
+ },
246
+ };
247
+ const internalSelection = compileSelectFields(
248
+ [...visibleSelection.fields, trustedLookupField],
249
+ internalSchema,
250
+ );
251
+ const permissionFilter = compileFilter(permission.filter, schema);
252
+ const table = quoteIdentifier(this.collection, 'collection name');
253
+ const data = [];
254
+
255
+ for (let offset = 0; offset < values.length; offset += QUERY_LIMITS.maxInValues) {
256
+ const chunk = values.slice(offset, offset + QUERY_LIMITS.maxInValues);
257
+ const filter = combineCompiledFilters(
258
+ permissionFilter,
259
+ compileFilter({ [trustedLookupField]: { _in: chunk } }, schema),
260
+ );
261
+ const [rows] = await this.database.query(
262
+ `SELECT ${internalSelection.sql} FROM ${table}${filter.sql} LIMIT ?`,
263
+ [...filter.params, chunk.length],
264
+ );
265
+ data.push(...rows);
266
+ }
267
+
268
+ return { data, visibleFields: visibleSelection.fields };
269
+ }
270
+
218
271
  async readOne(id, { fields = null } = {}) {
219
272
  const schema = await this.getCollectionSchema();
220
273
  const permission = await this.resolvePermission('read');
@@ -464,4 +517,4 @@ export class ItemsService extends BaseService {
464
517
  }
465
518
  }
466
519
 
467
- export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
520
+ export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
@@ -4,7 +4,7 @@ import { assertPermissionValidationRule } from '../permission-validation.js';
4
4
  import { compileFilter } from '../query.js';
5
5
  import { SchemaCache } from '../schema.js';
6
6
  import {
7
- assertActionOnlyPermissionPayload,
7
+ assertSystemPermissionPayload,
8
8
  assertSystemResourceAction,
9
9
  isPermissionManagedSystemResource,
10
10
  } from '../system-permissions.js';
@@ -86,6 +86,15 @@ export class PermissionsService extends BaseService {
86
86
  this.schemaCache = options.schemaCache ?? defaultSchemaCache;
87
87
  }
88
88
 
89
+ async action(event, payload) {
90
+ if (!this.emitter) return;
91
+ await this.emitter.action(event, payload, {
92
+ accountability: this.accountability,
93
+ requestId: this.requestId,
94
+ collection: 'yuncms_permissions',
95
+ });
96
+ }
97
+
89
98
  async #collectionSchema(collection) {
90
99
  const snapshot = this.schema ?? await this.schemaCache.get(this.database);
91
100
  const collectionSchema = snapshot.collections?.[collection];
@@ -100,7 +109,10 @@ export class PermissionsService extends BaseService {
100
109
  async resolve(action, collection) {
101
110
  assertAction(action);
102
111
  const key = cacheKey(this.accountability, action, collection);
103
- if (this.permissionCache?.has(key)) return this.permissionCache.get(key);
112
+ if (this.permissionCache) {
113
+ const cached = await this.permissionCache.get(key);
114
+ if (cached !== undefined) return cached;
115
+ }
104
116
 
105
117
  let permission;
106
118
  if (this.accountability.admin === true || this.accountability.system === true) {
@@ -159,7 +171,7 @@ export class PermissionsService extends BaseService {
159
171
  };
160
172
  }
161
173
 
162
- this.permissionCache?.set(key, permission);
174
+ if (this.permissionCache) await this.permissionCache.set(key, permission);
163
175
  return permission;
164
176
  }
165
177
 
@@ -205,7 +217,7 @@ export class PermissionsService extends BaseService {
205
217
  throw error;
206
218
  }
207
219
  assertSystemResourceAction(collectionSchema, action);
208
- assertActionOnlyPermissionPayload(collectionSchema, input);
220
+ assertSystemPermissionPayload(collectionSchema, action, input);
209
221
  }
210
222
 
211
223
  const fields = normalizePermissionFields(input.fields ?? null, collectionSchema);
@@ -224,11 +236,6 @@ export class PermissionsService extends BaseService {
224
236
  error.code = 'ROLE_NOT_FOUND';
225
237
  throw error;
226
238
  }
227
- if (collectionSchema.system && role.public) {
228
- const error = new Error('Public role cannot be granted access to protected system resources');
229
- error.code = 'PUBLIC_SYSTEM_ACCESS_FORBIDDEN';
230
- throw error;
231
- }
232
239
 
233
240
  const id = randomUUID();
234
241
  await this.database.query(
@@ -244,8 +251,10 @@ export class PermissionsService extends BaseService {
244
251
  validation == null ? null : JSON.stringify(validation),
245
252
  ],
246
253
  );
247
- this.permissionCache?.clear();
248
- return this.readOne(id);
254
+ if (this.permissionCache) await this.permissionCache.clear();
255
+ const permission = await this.readOne(id);
256
+ await this.action('permissions.create', { key: id, item: permission });
257
+ return permission;
249
258
  }
250
259
 
251
260
  async updateOne(id, patch = {}) {
@@ -269,7 +278,9 @@ export class PermissionsService extends BaseService {
269
278
  throw error;
270
279
  }
271
280
  const collectionSchema = await this.#collectionSchema(existing.collection);
272
- if (collectionSchema.system) assertActionOnlyPermissionPayload(collectionSchema, patch);
281
+ if (collectionSchema.system) {
282
+ assertSystemPermissionPayload(collectionSchema, existing.action, patch);
283
+ }
273
284
 
274
285
  const assignments = [];
275
286
  const params = [];
@@ -294,12 +305,20 @@ export class PermissionsService extends BaseService {
294
305
  `UPDATE yuncms_permissions SET ${assignments.join(', ')} WHERE id = ?`,
295
306
  params,
296
307
  );
297
- this.permissionCache?.clear();
298
- return this.readOne(id);
308
+ if (this.permissionCache) await this.permissionCache.clear();
309
+ const permission = await this.readOne(id);
310
+ await this.action('permissions.update', {
311
+ key: id,
312
+ before: existing,
313
+ item: permission,
314
+ changes: patch,
315
+ });
316
+ return permission;
299
317
  }
300
318
 
301
319
  async deleteOne(id) {
302
320
  assertPermissionManager(this.accountability);
321
+ const before = this.emitter ? await this.readOne(id) : null;
303
322
  const [result] = await this.database.query(
304
323
  'DELETE FROM yuncms_permissions WHERE id = ?',
305
324
  [id],
@@ -309,7 +328,8 @@ export class PermissionsService extends BaseService {
309
328
  error.code = 'PERMISSION_NOT_FOUND';
310
329
  throw error;
311
330
  }
312
- this.permissionCache?.clear();
331
+ if (this.permissionCache) await this.permissionCache.clear();
332
+ await this.action('permissions.delete', { key: id, before });
313
333
  return true;
314
334
  }
315
335
  }