@yunsoft/yuncms-core 0.1.0

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -0
  3. package/package.json +36 -0
  4. package/src/accountability.js +39 -0
  5. package/src/advisory-lock.js +30 -0
  6. package/src/auth/password.js +100 -0
  7. package/src/auth/tokens.js +38 -0
  8. package/src/auth/users-repository.js +25 -0
  9. package/src/bootstrap.js +41 -0
  10. package/src/config.js +106 -0
  11. package/src/context.js +33 -0
  12. package/src/database.js +31 -0
  13. package/src/errors.js +35 -0
  14. package/src/field-types.js +110 -0
  15. package/src/hooks.js +121 -0
  16. package/src/identifier.js +13 -0
  17. package/src/index.js +67 -0
  18. package/src/logger.js +56 -0
  19. package/src/m2m-lifecycle.js +139 -0
  20. package/src/mail/smtp-mailer.js +65 -0
  21. package/src/migrations/0001-system-schema.js +164 -0
  22. package/src/migrations/0002-session-access-tokens.js +10 -0
  23. package/src/migrations/0003-public-role-constraints.js +10 -0
  24. package/src/migrations/0004-auth-action-tokens.js +19 -0
  25. package/src/migrations.js +81 -0
  26. package/src/permission-validation.js +63 -0
  27. package/src/query.js +193 -0
  28. package/src/relation-expansion.js +216 -0
  29. package/src/retry.js +29 -0
  30. package/src/schema-metadata-repository.js +284 -0
  31. package/src/schema-version.js +21 -0
  32. package/src/schema.js +82 -0
  33. package/src/services/api-tokens-service.js +117 -0
  34. package/src/services/audit-service.js +182 -0
  35. package/src/services/auth-service.js +164 -0
  36. package/src/services/auth-tokens-service.js +215 -0
  37. package/src/services/base-service.js +26 -0
  38. package/src/services/collections-service.js +249 -0
  39. package/src/services/core-services.js +32 -0
  40. package/src/services/fields-service.js +471 -0
  41. package/src/services/file-reconciliation-service.js +127 -0
  42. package/src/services/files-service.js +227 -0
  43. package/src/services/items-service.js +445 -0
  44. package/src/services/permissions-service.js +282 -0
  45. package/src/services/relations-service.js +455 -0
  46. package/src/services/roles-service.js +160 -0
  47. package/src/services/schema-access.js +7 -0
  48. package/src/services/service-registry.js +32 -0
  49. package/src/services/sessions-service.js +179 -0
  50. package/src/services/users-service.js +215 -0
  51. package/src/setup.js +66 -0
  52. package/src/storage/local-storage-driver.js +105 -0
  53. package/src/storage/s3-storage-driver.js +150 -0
  54. package/src/storage/storage-registry.js +39 -0
  55. package/src/transaction.js +46 -0
@@ -0,0 +1,282 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { assertPermissionValidationRule } from '../permission-validation.js';
4
+ import { compileFilter } from '../query.js';
5
+ import { SchemaCache } from '../schema.js';
6
+ import { BaseService } from './base-service.js';
7
+
8
+ const ACTIONS = new Set(['create', 'read', 'update', 'delete']);
9
+ const defaultSchemaCache = new SchemaCache();
10
+
11
+ function parseJson(value, fallback = null) {
12
+ if (value == null) return fallback;
13
+ if (typeof value === 'object') return value;
14
+ try {
15
+ return JSON.parse(value);
16
+ } catch {
17
+ return fallback;
18
+ }
19
+ }
20
+
21
+ function forbidden(message) {
22
+ const error = new Error(message);
23
+ error.code = 'FORBIDDEN';
24
+ return error;
25
+ }
26
+
27
+ function assertAction(action) {
28
+ if (!ACTIONS.has(action)) {
29
+ const error = new Error(`Unsupported permission action: ${String(action)}`);
30
+ error.code = 'INVALID_PERMISSION';
31
+ throw error;
32
+ }
33
+ return action;
34
+ }
35
+
36
+ function assertPermissionManager(accountability) {
37
+ if (accountability.admin === true || accountability.system === true) return;
38
+ throw forbidden('Permission management requires administrator accountability');
39
+ }
40
+
41
+ function normalizePermissionFields(fields, collectionSchema) {
42
+ if (fields == null) return null;
43
+ if (!Array.isArray(fields) || fields.length === 0) {
44
+ const error = new Error('Permission fields must be a non-empty array or null');
45
+ error.code = 'INVALID_PERMISSION';
46
+ throw error;
47
+ }
48
+
49
+ const normalized = [...new Set(fields)];
50
+ for (const field of normalized) {
51
+ if (typeof field !== 'string' || !collectionSchema.fields?.[field]) {
52
+ const error = new Error(`Unknown permission field: ${String(field)}`);
53
+ error.code = 'INVALID_PERMISSION';
54
+ throw error;
55
+ }
56
+ }
57
+ return normalized;
58
+ }
59
+
60
+ function normalizePermissionRow(row) {
61
+ if (!row) return null;
62
+ return {
63
+ ...row,
64
+ fields: parseJson(row.fields, null),
65
+ filter: parseJson(row.filter, null),
66
+ validation: parseJson(row.validation, null),
67
+ };
68
+ }
69
+
70
+ function cacheKey(accountability, action, collection) {
71
+ return [
72
+ accountability.system === true ? 'system' : accountability.admin === true ? 'admin' : accountability.role ?? 'none',
73
+ collection,
74
+ action,
75
+ ].join(':');
76
+ }
77
+
78
+ export class PermissionsService extends BaseService {
79
+ constructor(options = {}) {
80
+ super(options);
81
+ this.schemaCache = options.schemaCache ?? defaultSchemaCache;
82
+ }
83
+
84
+ async resolve(action, collection) {
85
+ assertAction(action);
86
+ const key = cacheKey(this.accountability, action, collection);
87
+ if (this.permissionCache?.has(key)) return this.permissionCache.get(key);
88
+
89
+ let permission;
90
+ if (this.accountability.admin === true || this.accountability.system === true) {
91
+ permission = {
92
+ fullAccess: true,
93
+ role: this.accountability.role,
94
+ collection,
95
+ action,
96
+ fields: null,
97
+ filter: null,
98
+ validation: null,
99
+ };
100
+ } else {
101
+ if (!this.accountability.role) {
102
+ throw forbidden(`No role is available for ${action} access to ${collection}`);
103
+ }
104
+
105
+ const [rows] = await this.database.query(
106
+ `SELECT id, role, collection, action, fields, filter, validation
107
+ FROM yuncms_permissions
108
+ WHERE role = ? AND collection = ? AND action = ?
109
+ LIMIT 1`,
110
+ [this.accountability.role, collection, action],
111
+ );
112
+ const row = normalizePermissionRow(rows[0]);
113
+ if (!row) throw forbidden(`Role has no ${action} permission for ${collection}`);
114
+
115
+ if (row.fields !== null && (!Array.isArray(row.fields) || row.fields.some((field) => typeof field !== 'string'))) {
116
+ const error = new Error(`Permission ${row.id} contains invalid field metadata`);
117
+ error.code = 'INVALID_PERMISSION';
118
+ throw error;
119
+ }
120
+
121
+ permission = {
122
+ fullAccess: false,
123
+ id: row.id,
124
+ role: row.role,
125
+ collection: row.collection,
126
+ action: row.action,
127
+ fields: row.fields,
128
+ filter: row.filter,
129
+ validation: row.validation,
130
+ };
131
+ }
132
+
133
+ this.permissionCache?.set(key, permission);
134
+ return permission;
135
+ }
136
+
137
+ async readMany() {
138
+ assertPermissionManager(this.accountability);
139
+ const [rows] = await this.database.query(
140
+ `SELECT id, role, collection, action, fields, filter, validation, created_at, updated_at
141
+ FROM yuncms_permissions
142
+ ORDER BY role, collection, action`,
143
+ );
144
+ return rows.map(normalizePermissionRow);
145
+ }
146
+
147
+ async readOne(id) {
148
+ assertPermissionManager(this.accountability);
149
+ const [rows] = await this.database.query(
150
+ `SELECT id, role, collection, action, fields, filter, validation, created_at, updated_at
151
+ FROM yuncms_permissions WHERE id = ? LIMIT 1`,
152
+ [id],
153
+ );
154
+ return normalizePermissionRow(rows[0]);
155
+ }
156
+
157
+ async #collectionSchema(collection) {
158
+ const snapshot = this.schema ?? await this.schemaCache.get(this.database);
159
+ const collectionSchema = snapshot.collections?.[collection];
160
+ if (!collectionSchema) {
161
+ const error = new Error(`Unknown permission collection: ${collection}`);
162
+ error.code = 'COLLECTION_NOT_FOUND';
163
+ throw error;
164
+ }
165
+ return collectionSchema;
166
+ }
167
+
168
+ async createOne(input = {}) {
169
+ assertPermissionManager(this.accountability);
170
+ const action = assertAction(input.action);
171
+ if (!input.role) {
172
+ const error = new Error('Permission role is required');
173
+ error.code = 'INVALID_PERMISSION';
174
+ throw error;
175
+ }
176
+ if (!input.collection || typeof input.collection !== 'string') {
177
+ const error = new Error('Permission collection is required');
178
+ error.code = 'INVALID_PERMISSION';
179
+ throw error;
180
+ }
181
+
182
+ const collectionSchema = await this.#collectionSchema(input.collection);
183
+ const fields = normalizePermissionFields(input.fields ?? null, collectionSchema);
184
+ const filter = input.filter ?? null;
185
+ const validation = input.validation ?? null;
186
+ if (filter !== null) compileFilter(filter, collectionSchema);
187
+ assertPermissionValidationRule(validation, collectionSchema);
188
+
189
+ const [roleRows] = await this.database.query(
190
+ 'SELECT id FROM yuncms_roles WHERE id = ? LIMIT 1',
191
+ [input.role],
192
+ );
193
+ if (!roleRows[0]) {
194
+ const error = new Error(`Unknown permission role: ${input.role}`);
195
+ error.code = 'ROLE_NOT_FOUND';
196
+ throw error;
197
+ }
198
+
199
+ const id = randomUUID();
200
+ await this.database.query(
201
+ `INSERT INTO yuncms_permissions (id, role, collection, action, fields, filter, validation)
202
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
203
+ [
204
+ id,
205
+ input.role,
206
+ input.collection,
207
+ action,
208
+ fields == null ? null : JSON.stringify(fields),
209
+ filter == null ? null : JSON.stringify(filter),
210
+ validation == null ? null : JSON.stringify(validation),
211
+ ],
212
+ );
213
+ this.permissionCache?.clear();
214
+ return this.readOne(id);
215
+ }
216
+
217
+ async updateOne(id, patch = {}) {
218
+ assertPermissionManager(this.accountability);
219
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
220
+ const error = new Error('Permission patch must be an object');
221
+ error.code = 'INVALID_PERMISSION';
222
+ throw error;
223
+ }
224
+ const keys = Object.keys(patch);
225
+ if (keys.length === 0 || keys.some((key) => !['fields', 'filter', 'validation'].includes(key))) {
226
+ const error = new Error('Permission update supports fields, filter and validation only');
227
+ error.code = 'INVALID_PERMISSION';
228
+ throw error;
229
+ }
230
+
231
+ const existing = await this.readOne(id);
232
+ if (!existing) {
233
+ const error = new Error(`Unknown permission: ${id}`);
234
+ error.code = 'PERMISSION_NOT_FOUND';
235
+ throw error;
236
+ }
237
+ const collectionSchema = await this.#collectionSchema(existing.collection);
238
+
239
+ const assignments = [];
240
+ const params = [];
241
+ if (Object.hasOwn(patch, 'fields')) {
242
+ const fields = normalizePermissionFields(patch.fields, collectionSchema);
243
+ assignments.push('fields = ?');
244
+ params.push(fields == null ? null : JSON.stringify(fields));
245
+ }
246
+ if (Object.hasOwn(patch, 'filter')) {
247
+ if (patch.filter != null) compileFilter(patch.filter, collectionSchema);
248
+ assignments.push('filter = ?');
249
+ params.push(patch.filter == null ? null : JSON.stringify(patch.filter));
250
+ }
251
+ if (Object.hasOwn(patch, 'validation')) {
252
+ assertPermissionValidationRule(patch.validation, collectionSchema);
253
+ assignments.push('validation = ?');
254
+ params.push(patch.validation == null ? null : JSON.stringify(patch.validation));
255
+ }
256
+
257
+ params.push(id);
258
+ await this.database.query(
259
+ `UPDATE yuncms_permissions SET ${assignments.join(', ')} WHERE id = ?`,
260
+ params,
261
+ );
262
+ this.permissionCache?.clear();
263
+ return this.readOne(id);
264
+ }
265
+
266
+ async deleteOne(id) {
267
+ assertPermissionManager(this.accountability);
268
+ const [result] = await this.database.query(
269
+ 'DELETE FROM yuncms_permissions WHERE id = ?',
270
+ [id],
271
+ );
272
+ if (result.affectedRows !== 1) {
273
+ const error = new Error(`Unknown permission: ${id}`);
274
+ error.code = 'PERMISSION_NOT_FOUND';
275
+ throw error;
276
+ }
277
+ this.permissionCache?.clear();
278
+ return true;
279
+ }
280
+ }
281
+
282
+ export { cacheKey };