@yunsoft/yuncms-core 0.1.1 → 0.1.2

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.
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
3
3
  import { hashPassword } from '../auth/password.js';
4
4
  import { withTransaction } from '../transaction.js';
5
5
  import { BaseService } from './base-service.js';
6
+ import { resolveSystemResourceAccess } from './system-resource-access.js';
6
7
 
7
8
  const USER_STATUSES = new Set(['active', 'suspended', 'disabled']);
8
9
  const USER_UPDATE_KEYS = new Set(['email', 'role', 'status']);
@@ -22,11 +23,10 @@ function normalizeEmail(email) {
22
23
  return normalized;
23
24
  }
24
25
 
25
- function assertUserManager(accountability) {
26
- if (accountability.admin === true || accountability.system === true) return;
27
- const error = new Error('User management requires administrator accountability');
26
+ function forbidden(message) {
27
+ const error = new Error(message);
28
28
  error.code = 'FORBIDDEN';
29
- throw error;
29
+ return error;
30
30
  }
31
31
 
32
32
  function assertStatus(status) {
@@ -38,19 +38,65 @@ function assertStatus(status) {
38
38
  return status;
39
39
  }
40
40
 
41
- async function assertRoleExists(database, role) {
41
+ function assertCredentialManager(accountability) {
42
+ if (accountability.admin === true || accountability.system === true) return;
43
+ throw forbidden('Changing another user password requires administrator accountability');
44
+ }
45
+
46
+ async function assertRoleAssignable(database, role, accountability) {
42
47
  if (role == null) return;
43
- const [roleRows] = await database.query('SELECT id FROM yuncms_roles WHERE id = ? LIMIT 1', [role]);
44
- if (!roleRows[0]) {
48
+ const [roleRows] = await database.query(
49
+ 'SELECT id, admin, public FROM yuncms_roles WHERE id = ? LIMIT 1',
50
+ [role],
51
+ );
52
+ const targetRole = roleRows[0];
53
+ if (!targetRole) {
45
54
  const error = new Error(`Unknown role: ${role}`);
46
55
  error.code = 'ROLE_NOT_FOUND';
47
56
  throw error;
48
57
  }
58
+ if (targetRole.public) {
59
+ const error = new Error('The public role cannot be assigned to an authenticated user');
60
+ error.code = 'INVALID_ROLE';
61
+ throw error;
62
+ }
63
+ if (targetRole.admin && accountability.admin !== true && accountability.system !== true) {
64
+ throw forbidden('Only an administrator can assign the administrator role');
65
+ }
66
+ }
67
+
68
+ async function assertTargetManageable(database, id, accountability) {
69
+ if (accountability.admin === true || accountability.system === true) return;
70
+ const [rows] = await database.query(
71
+ `SELECT u.id, r.admin AS role_admin
72
+ FROM yuncms_users u
73
+ LEFT JOIN yuncms_roles r ON r.id = u.role
74
+ WHERE u.id = ?
75
+ LIMIT 1`,
76
+ [id],
77
+ );
78
+ if (!rows[0]) {
79
+ const error = new Error(`Unknown user: ${id}`);
80
+ error.code = 'USER_NOT_FOUND';
81
+ throw error;
82
+ }
83
+ if (rows[0].role_admin) throw forbidden('Delegated user managers cannot modify administrator accounts');
49
84
  }
50
85
 
51
86
  export class UsersService extends BaseService {
87
+ async #readOneUnsafe(id) {
88
+ const [rows] = await this.database.query(
89
+ `SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
90
+ FROM yuncms_users
91
+ WHERE id = ?
92
+ LIMIT 1`,
93
+ [id],
94
+ );
95
+ return rows[0] ?? null;
96
+ }
97
+
52
98
  async readMany() {
53
- assertUserManager(this.accountability);
99
+ await resolveSystemResourceAccess(this, 'read', 'yuncms_users');
54
100
  const [rows] = await this.database.query(
55
101
  `SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
56
102
  FROM yuncms_users
@@ -61,26 +107,19 @@ export class UsersService extends BaseService {
61
107
 
62
108
  async readOne(id) {
63
109
  const self = this.accountability.user === id;
64
- if (!self) assertUserManager(this.accountability);
65
-
66
- const [rows] = await this.database.query(
67
- `SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
68
- FROM yuncms_users
69
- WHERE id = ?
70
- LIMIT 1`,
71
- [id],
72
- );
73
- return rows[0] ?? null;
110
+ if (!self) await resolveSystemResourceAccess(this, 'read', 'yuncms_users');
111
+ return this.#readOneUnsafe(id);
74
112
  }
75
113
 
76
114
  async createOne(input = {}) {
77
- assertUserManager(this.accountability);
115
+ await resolveSystemResourceAccess(this, 'create', 'yuncms_users');
78
116
  const email = normalizeEmail(input.email);
79
117
  const status = assertStatus(input.status ?? 'active');
80
- await assertRoleExists(this.database, input.role ?? null);
118
+ await assertRoleAssignable(this.database, input.role ?? null, this.accountability);
81
119
 
82
120
  const passwordHash = await hashPassword(input.password);
83
121
  const id = randomUUID();
122
+ const verifiedAt = new Date();
84
123
  await this.database.query(
85
124
  `INSERT INTO yuncms_users (id, email, password_hash, role, status, email_verified_at)
86
125
  VALUES (?, ?, ?, ?, ?, ?)`,
@@ -90,15 +129,16 @@ export class UsersService extends BaseService {
90
129
  passwordHash,
91
130
  input.role ?? null,
92
131
  status,
93
- input.emailVerified === true ? new Date() : null,
132
+ verifiedAt,
94
133
  ],
95
134
  );
96
135
 
97
- return this.readOne(id);
136
+ return this.#readOneUnsafe(id);
98
137
  }
99
138
 
100
139
  async updateOne(id, patch = {}) {
101
- assertUserManager(this.accountability);
140
+ await resolveSystemResourceAccess(this, 'update', 'yuncms_users');
141
+ await assertTargetManageable(this.database, id, this.accountability);
102
142
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
103
143
  const error = new Error('User patch must be an object');
104
144
  error.code = 'INVALID_PAYLOAD';
@@ -114,12 +154,14 @@ export class UsersService extends BaseService {
114
154
  if (Object.hasOwn(patch, 'status')) {
115
155
  assertStatus(patch.status);
116
156
  if (this.accountability.user === id && patch.status !== 'active') {
117
- const error = new Error('An administrator cannot suspend or disable their own active session');
157
+ const error = new Error('A user cannot suspend or disable their own active session');
118
158
  error.code = 'SELF_ADMIN_MUTATION_FORBIDDEN';
119
159
  throw error;
120
160
  }
121
161
  }
122
- if (Object.hasOwn(patch, 'role')) await assertRoleExists(this.database, patch.role);
162
+ if (Object.hasOwn(patch, 'role')) {
163
+ await assertRoleAssignable(this.database, patch.role, this.accountability);
164
+ }
123
165
 
124
166
  return withTransaction(this.database, async (connection) => {
125
167
  const assignments = [];
@@ -163,9 +205,10 @@ export class UsersService extends BaseService {
163
205
  }
164
206
 
165
207
  async deleteOne(id) {
166
- assertUserManager(this.accountability);
208
+ await resolveSystemResourceAccess(this, 'delete', 'yuncms_users');
209
+ await assertTargetManageable(this.database, id, this.accountability);
167
210
  if (this.accountability.user === id) {
168
- const error = new Error('An administrator cannot delete their own user account');
211
+ const error = new Error('A user cannot delete their own account from an active session');
169
212
  error.code = 'SELF_ADMIN_MUTATION_FORBIDDEN';
170
213
  throw error;
171
214
  }
@@ -180,7 +223,7 @@ export class UsersService extends BaseService {
180
223
 
181
224
  async updatePassword(id, password) {
182
225
  const self = this.accountability.user === id;
183
- if (!self) assertUserManager(this.accountability);
226
+ if (!self) assertCredentialManager(this.accountability);
184
227
 
185
228
  const passwordHash = await hashPassword(password);
186
229
  const connection = await this.database.getConnection();
package/src/setup.js CHANGED
@@ -103,7 +103,6 @@ export async function createInitialAdmin(pool, { email, password } = {}) {
103
103
  password,
104
104
  role: roleId,
105
105
  status: 'active',
106
- emailVerified: true,
107
106
  });
108
107
 
109
108
  return {
@@ -112,4 +111,4 @@ export async function createInitialAdmin(pool, { email, password } = {}) {
112
111
  role: roleId,
113
112
  };
114
113
  });
115
- }
114
+ }
@@ -0,0 +1,146 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { compileFieldColumn } from './field-types.js';
4
+ import { quoteIdentifier } from './identifier.js';
5
+
6
+ export const COLLECTION_SYSTEM_FIELDS = Object.freeze([
7
+ 'created_at',
8
+ 'updated_at',
9
+ 'created_by',
10
+ 'updated_by',
11
+ ]);
12
+
13
+ const SYSTEM_FIELD_DEFINITIONS = Object.freeze({
14
+ created_at: Object.freeze({
15
+ field: 'created_at',
16
+ type: 'timestamp',
17
+ required: true,
18
+ readonly: true,
19
+ interface: 'datetime',
20
+ special: 'date-created',
21
+ defaultPreset: 'now',
22
+ }),
23
+ updated_at: Object.freeze({
24
+ field: 'updated_at',
25
+ type: 'timestamp',
26
+ required: true,
27
+ readonly: true,
28
+ interface: 'datetime',
29
+ special: 'date-updated',
30
+ defaultPreset: 'now',
31
+ autoUpdate: true,
32
+ }),
33
+ created_by: Object.freeze({
34
+ field: 'created_by',
35
+ type: 'uuid',
36
+ required: false,
37
+ readonly: true,
38
+ interface: 'user',
39
+ special: 'user-created',
40
+ }),
41
+ updated_by: Object.freeze({
42
+ field: 'updated_by',
43
+ type: 'uuid',
44
+ required: false,
45
+ readonly: true,
46
+ interface: 'user',
47
+ special: 'user-updated',
48
+ }),
49
+ });
50
+
51
+ function invalidSystemFields(message) {
52
+ const error = new Error(message);
53
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
54
+ return error;
55
+ }
56
+
57
+ export function normalizeCollectionSystemFields(value) {
58
+ if (value == null) return [];
59
+ if (!Array.isArray(value)) throw invalidSystemFields('systemFields must be an array');
60
+
61
+ const normalized = [...new Set(value)];
62
+ for (const field of normalized) {
63
+ if (!COLLECTION_SYSTEM_FIELDS.includes(field)) {
64
+ throw invalidSystemFields(`Unsupported system field: ${String(field)}`);
65
+ }
66
+ }
67
+ return COLLECTION_SYSTEM_FIELDS.filter((field) => normalized.includes(field));
68
+ }
69
+
70
+ export function systemFieldDefinition(field) {
71
+ return SYSTEM_FIELD_DEFINITIONS[field] ?? null;
72
+ }
73
+
74
+ function foreignKeyName(collection, field) {
75
+ const digest = createHash('sha256')
76
+ .update(`${collection}:${field}:system-user`)
77
+ .digest('hex')
78
+ .slice(0, 24);
79
+ return `ysf_${digest}`;
80
+ }
81
+
82
+ export function compileCollectionSystemFields(collection, requestedFields) {
83
+ const fields = normalizeCollectionSystemFields(requestedFields);
84
+ const columns = [];
85
+ const constraints = [];
86
+ const metadata = [];
87
+
88
+ for (const field of fields) {
89
+ const definition = SYSTEM_FIELD_DEFINITIONS[field];
90
+ const compiled = compileFieldColumn(definition);
91
+ columns.push(`${quoteIdentifier(field, 'system field')} ${compiled.sql}`);
92
+ metadata.push({
93
+ collection,
94
+ field,
95
+ type: definition.type,
96
+ required: definition.required,
97
+ readonly: true,
98
+ hidden: false,
99
+ interface: definition.interface,
100
+ schemaMetadata: {
101
+ ...compiled.schemaMetadata,
102
+ special: definition.special,
103
+ systemManaged: true,
104
+ },
105
+ });
106
+
107
+ if (field === 'created_by' || field === 'updated_by') {
108
+ constraints.push(
109
+ `CONSTRAINT ${quoteIdentifier(foreignKeyName(collection, field), 'system field constraint')} `
110
+ + `FOREIGN KEY (${quoteIdentifier(field, 'system field')}) REFERENCES yuncms_users (id) ON DELETE SET NULL`,
111
+ );
112
+ }
113
+ }
114
+
115
+ return { fields, columns, constraints, metadata };
116
+ }
117
+
118
+ function parseSchemaMetadata(value) {
119
+ if (value == null) return {};
120
+ if (typeof value === 'object') return value;
121
+ try {
122
+ return JSON.parse(value) ?? {};
123
+ } catch {
124
+ return {};
125
+ }
126
+ }
127
+
128
+ export function fieldSpecial(fieldSchema) {
129
+ return parseSchemaMetadata(fieldSchema?.schema_metadata).special ?? null;
130
+ }
131
+
132
+ export function isSystemManagedField(fieldSchema) {
133
+ return Boolean(fieldSpecial(fieldSchema));
134
+ }
135
+
136
+ export function systemMutationEntries(schema, accountability, operation, now = new Date()) {
137
+ const entries = [];
138
+ for (const fieldSchema of Object.values(schema?.fields ?? {})) {
139
+ const special = fieldSpecial(fieldSchema);
140
+ if (operation === 'create' && special === 'date-created') entries.push([fieldSchema.field, now]);
141
+ if ((operation === 'create' || operation === 'update') && special === 'date-updated') entries.push([fieldSchema.field, now]);
142
+ if (operation === 'create' && special === 'user-created') entries.push([fieldSchema.field, accountability?.user ?? null]);
143
+ if ((operation === 'create' || operation === 'update') && special === 'user-updated') entries.push([fieldSchema.field, accountability?.user ?? null]);
144
+ }
145
+ return entries;
146
+ }
@@ -0,0 +1,54 @@
1
+ const ALL_ACTIONS = Object.freeze(['read', 'create', 'update', 'delete']);
2
+
3
+ function parseMetadata(value) {
4
+ if (value == null) return {};
5
+ if (typeof value === 'object') return value;
6
+ try {
7
+ return JSON.parse(value) ?? {};
8
+ } catch {
9
+ return {};
10
+ }
11
+ }
12
+
13
+ function isEnabledFlag(value) {
14
+ return value === true || value === 1;
15
+ }
16
+
17
+ export function systemPermissionConfig(collectionSchema) {
18
+ if (!collectionSchema?.system) return null;
19
+ const metadata = parseMetadata(collectionSchema.metadata);
20
+ if (!isEnabledFlag(metadata.permissionManaged)) return null;
21
+ const allowedActions = Array.isArray(metadata.allowedActions)
22
+ ? metadata.allowedActions.filter((action) => ALL_ACTIONS.includes(action))
23
+ : [];
24
+ return Object.freeze({
25
+ resource: metadata.resource ?? collectionSchema.collection,
26
+ mode: metadata.permissionMode ?? 'action-only',
27
+ allowedActions: Object.freeze([...new Set(allowedActions)]),
28
+ });
29
+ }
30
+
31
+ export function isPermissionManagedSystemResource(collectionSchema) {
32
+ return Boolean(systemPermissionConfig(collectionSchema));
33
+ }
34
+
35
+ export function assertSystemResourceAction(collectionSchema, action) {
36
+ const config = systemPermissionConfig(collectionSchema);
37
+ if (!config) return null;
38
+ if (!config.allowedActions.includes(action)) {
39
+ const error = new Error(`Action ${action} is protected for system resource ${collectionSchema.collection}`);
40
+ error.code = 'SYSTEM_PERMISSION_ACTION_PROTECTED';
41
+ throw error;
42
+ }
43
+ return config;
44
+ }
45
+
46
+ export function assertActionOnlyPermissionPayload(collectionSchema, { fields, filter, validation } = {}) {
47
+ const config = systemPermissionConfig(collectionSchema);
48
+ if (!config || config.mode !== 'action-only') return;
49
+ if (fields != null || filter != null || validation != null) {
50
+ const error = new Error(`System resource ${collectionSchema.collection} supports action-level permissions only`);
51
+ error.code = 'SYSTEM_PERMISSION_ADVANCED_UNSUPPORTED';
52
+ throw error;
53
+ }
54
+ }