@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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/package.json +36 -0
- package/src/accountability.js +39 -0
- package/src/advisory-lock.js +30 -0
- package/src/auth/password.js +100 -0
- package/src/auth/tokens.js +38 -0
- package/src/auth/users-repository.js +25 -0
- package/src/bootstrap.js +41 -0
- package/src/config.js +106 -0
- package/src/context.js +33 -0
- package/src/database.js +31 -0
- package/src/errors.js +35 -0
- package/src/field-types.js +110 -0
- package/src/hooks.js +121 -0
- package/src/identifier.js +13 -0
- package/src/index.js +67 -0
- package/src/logger.js +56 -0
- package/src/m2m-lifecycle.js +139 -0
- package/src/mail/smtp-mailer.js +65 -0
- package/src/migrations/0001-system-schema.js +164 -0
- package/src/migrations/0002-session-access-tokens.js +10 -0
- package/src/migrations/0003-public-role-constraints.js +10 -0
- package/src/migrations/0004-auth-action-tokens.js +19 -0
- package/src/migrations.js +81 -0
- package/src/permission-validation.js +63 -0
- package/src/query.js +193 -0
- package/src/relation-expansion.js +216 -0
- package/src/retry.js +29 -0
- package/src/schema-metadata-repository.js +284 -0
- package/src/schema-version.js +21 -0
- package/src/schema.js +82 -0
- package/src/services/api-tokens-service.js +117 -0
- package/src/services/audit-service.js +182 -0
- package/src/services/auth-service.js +164 -0
- package/src/services/auth-tokens-service.js +215 -0
- package/src/services/base-service.js +26 -0
- package/src/services/collections-service.js +249 -0
- package/src/services/core-services.js +32 -0
- package/src/services/fields-service.js +471 -0
- package/src/services/file-reconciliation-service.js +127 -0
- package/src/services/files-service.js +227 -0
- package/src/services/items-service.js +445 -0
- package/src/services/permissions-service.js +282 -0
- package/src/services/relations-service.js +455 -0
- package/src/services/roles-service.js +160 -0
- package/src/services/schema-access.js +7 -0
- package/src/services/service-registry.js +32 -0
- package/src/services/sessions-service.js +179 -0
- package/src/services/users-service.js +215 -0
- package/src/setup.js +66 -0
- package/src/storage/local-storage-driver.js +105 -0
- package/src/storage/s3-storage-driver.js +150 -0
- package/src/storage/storage-registry.js +39 -0
- package/src/transaction.js +46 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createPublicAccountability } from '../accountability.js';
|
|
2
|
+
import { verifyPassword } from '../auth/password.js';
|
|
3
|
+
import { hashToken, tokenType } from '../auth/tokens.js';
|
|
4
|
+
import { readAuthenticationUserByEmail } from '../auth/users-repository.js';
|
|
5
|
+
import { BaseService } from './base-service.js';
|
|
6
|
+
import { SessionsService } from './sessions-service.js';
|
|
7
|
+
|
|
8
|
+
const DUMMY_PASSWORD_HASH = 'scrypt$N=65536,r=8,p=1,keyLength=64$AAAAAAAAAAAAAAAAAAAAAA$wPIB-ojIevW9SJ6ou99EIix5AukscH1McxhCNVsi1eVkhsxb5QzXquW0YeAFglU5Vh-NthiqKH90soC0JgEJPQ';
|
|
9
|
+
|
|
10
|
+
function invalidLogin() {
|
|
11
|
+
const error = new Error('Invalid email or password');
|
|
12
|
+
error.code = 'INVALID_CREDENTIALS';
|
|
13
|
+
return error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function invalidToken() {
|
|
17
|
+
const error = new Error('Invalid or expired authentication token');
|
|
18
|
+
error.code = 'INVALID_CREDENTIALS';
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeLoginEmail(email) {
|
|
23
|
+
if (typeof email !== 'string') return null;
|
|
24
|
+
const normalized = email.trim().toLowerCase();
|
|
25
|
+
if (!normalized || normalized.length > 191 || !normalized.includes('@')) return null;
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function publicUser(user) {
|
|
30
|
+
return {
|
|
31
|
+
id: user.id,
|
|
32
|
+
email: user.email,
|
|
33
|
+
role: user.role ?? null,
|
|
34
|
+
status: user.status,
|
|
35
|
+
email_verified_at: user.email_verified_at ?? null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class AuthService extends BaseService {
|
|
40
|
+
createSessionsService() {
|
|
41
|
+
return new SessionsService({
|
|
42
|
+
accountability: this.accountability,
|
|
43
|
+
database: this.database,
|
|
44
|
+
schema: this.schema,
|
|
45
|
+
emitter: this.emitter,
|
|
46
|
+
logger: this.logger,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async resolvePublicAccountability() {
|
|
51
|
+
const [rows] = await this.database.query(
|
|
52
|
+
`SELECT id
|
|
53
|
+
FROM yuncms_roles
|
|
54
|
+
WHERE public = 1
|
|
55
|
+
ORDER BY created_at ASC
|
|
56
|
+
LIMIT 2`,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (rows.length > 1) {
|
|
60
|
+
const error = new Error('Multiple public roles are configured');
|
|
61
|
+
error.code = 'PUBLIC_ROLE_AMBIGUOUS';
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return createPublicAccountability({ role: rows[0]?.id ?? null });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async login({ email, password, ip = null, userAgent = null } = {}) {
|
|
69
|
+
const normalizedEmail = normalizeLoginEmail(email);
|
|
70
|
+
const user = normalizedEmail
|
|
71
|
+
? await readAuthenticationUserByEmail(this.database, normalizedEmail)
|
|
72
|
+
: null;
|
|
73
|
+
|
|
74
|
+
const passwordMatches = await verifyPassword(
|
|
75
|
+
typeof password === 'string' ? password : '',
|
|
76
|
+
user?.password_hash ?? DUMMY_PASSWORD_HASH,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (!user || !passwordMatches || user.status !== 'active') {
|
|
80
|
+
throw invalidLogin();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const tokens = await this.createSessionsService().createForUser(user, { ip, userAgent });
|
|
84
|
+
return {
|
|
85
|
+
user: publicUser(user),
|
|
86
|
+
...tokens,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async authenticateAccessToken(token) {
|
|
91
|
+
return this.createSessionsService().authenticateAccessToken(token);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async authenticateApiToken(token) {
|
|
95
|
+
if (tokenType(token) !== 'api') throw invalidToken();
|
|
96
|
+
const [rows] = await this.database.query(
|
|
97
|
+
`SELECT t.id AS api_token_id, t.user, u.email, u.role, u.status,
|
|
98
|
+
r.admin AS role_admin
|
|
99
|
+
FROM yuncms_api_tokens t
|
|
100
|
+
INNER JOIN yuncms_users u ON u.id = t.user
|
|
101
|
+
LEFT JOIN yuncms_roles r ON r.id = u.role
|
|
102
|
+
WHERE t.token_hash = ?
|
|
103
|
+
AND (t.expires_at IS NULL OR t.expires_at > CURRENT_TIMESTAMP(3))
|
|
104
|
+
AND u.status = 'active'
|
|
105
|
+
LIMIT 1`,
|
|
106
|
+
[hashToken(token)],
|
|
107
|
+
);
|
|
108
|
+
const row = rows[0];
|
|
109
|
+
if (!row) throw invalidToken();
|
|
110
|
+
|
|
111
|
+
await this.database.query(
|
|
112
|
+
`UPDATE yuncms_api_tokens t
|
|
113
|
+
INNER JOIN yuncms_users u ON u.id = t.user
|
|
114
|
+
SET t.last_used_at = CURRENT_TIMESTAMP(3), u.last_access = CURRENT_TIMESTAMP(3)
|
|
115
|
+
WHERE t.id = ?`,
|
|
116
|
+
[row.api_token_id],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
user: row.user,
|
|
121
|
+
role: row.role ?? null,
|
|
122
|
+
admin: Boolean(row.role_admin),
|
|
123
|
+
email: row.email,
|
|
124
|
+
session: null,
|
|
125
|
+
apiToken: row.api_token_id,
|
|
126
|
+
authMethod: 'api_token',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async authenticateBearerToken(token) {
|
|
131
|
+
const type = tokenType(token);
|
|
132
|
+
if (type === 'access') return this.authenticateAccessToken(token);
|
|
133
|
+
if (type === 'api') return this.authenticateApiToken(token);
|
|
134
|
+
throw invalidToken();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async refresh(refreshToken) {
|
|
138
|
+
const result = await this.createSessionsService().rotateRefreshToken(refreshToken);
|
|
139
|
+
return {
|
|
140
|
+
user: {
|
|
141
|
+
id: result.user,
|
|
142
|
+
email: result.email,
|
|
143
|
+
role: result.role,
|
|
144
|
+
},
|
|
145
|
+
access_token: result.access_token,
|
|
146
|
+
access_expires_at: result.access_expires_at,
|
|
147
|
+
refresh_token: result.refresh_token,
|
|
148
|
+
refresh_expires_at: result.refresh_expires_at,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async logout(accessToken) {
|
|
153
|
+
return this.createSessionsService().revokeByAccessToken(accessToken);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async logoutAll() {
|
|
157
|
+
if (!this.accountability.user) {
|
|
158
|
+
const error = new Error('Authenticated user is required');
|
|
159
|
+
error.code = 'UNAUTHORIZED';
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
return this.createSessionsService().revokeAllForUser(this.accountability.user);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { hashPassword } from '../auth/password.js';
|
|
4
|
+
import { createOpaqueToken, hashToken, tokenType } from '../auth/tokens.js';
|
|
5
|
+
import { readAuthenticationUserByEmail } from '../auth/users-repository.js';
|
|
6
|
+
import { BaseService } from './base-service.js';
|
|
7
|
+
import { normalizeEmail } from './users-service.js';
|
|
8
|
+
|
|
9
|
+
const TOKEN_TYPES = Object.freeze({
|
|
10
|
+
reset: 'password_reset',
|
|
11
|
+
verify: 'email_verification',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TTL_MS = Object.freeze({
|
|
15
|
+
reset: 60 * 60 * 1000,
|
|
16
|
+
verify: 24 * 60 * 60 * 1000,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function invalidActionToken() {
|
|
20
|
+
const error = new Error('Invalid or expired action token');
|
|
21
|
+
error.code = 'INVALID_TOKEN';
|
|
22
|
+
return error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assertPositiveTtl(ttlMs) {
|
|
26
|
+
if (!Number.isInteger(ttlMs) || ttlMs < 60_000 || ttlMs > 7 * 24 * 60 * 60 * 1000) {
|
|
27
|
+
const error = new Error('Token TTL must be between 1 minute and 7 days');
|
|
28
|
+
error.code = 'INVALID_PAYLOAD';
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class AuthTokensService extends BaseService {
|
|
34
|
+
async createForUser(userId, type, { ttlMs = DEFAULT_TTL_MS[type] } = {}) {
|
|
35
|
+
if (!TOKEN_TYPES[type]) {
|
|
36
|
+
const error = new Error(`Unsupported auth token type: ${type}`);
|
|
37
|
+
error.code = 'INVALID_PAYLOAD';
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
assertPositiveTtl(ttlMs);
|
|
41
|
+
|
|
42
|
+
const [users] = await this.database.query(
|
|
43
|
+
'SELECT id, status FROM yuncms_users WHERE id = ? LIMIT 1',
|
|
44
|
+
[userId],
|
|
45
|
+
);
|
|
46
|
+
if (!users[0] || users[0].status !== 'active') {
|
|
47
|
+
const error = new Error(`Active user not found: ${userId}`);
|
|
48
|
+
error.code = 'USER_NOT_FOUND';
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const generated = createOpaqueToken(type);
|
|
53
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
54
|
+
const connection = await this.database.getConnection();
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
await connection.beginTransaction();
|
|
58
|
+
await connection.query(
|
|
59
|
+
`DELETE FROM yuncms_auth_tokens
|
|
60
|
+
WHERE user = ? AND type = ? AND used_at IS NULL`,
|
|
61
|
+
[userId, TOKEN_TYPES[type]],
|
|
62
|
+
);
|
|
63
|
+
await connection.query(
|
|
64
|
+
`INSERT INTO yuncms_auth_tokens (id, user, type, token_hash, expires_at)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
66
|
+
[randomUUID(), userId, TOKEN_TYPES[type], generated.hash, expiresAt],
|
|
67
|
+
);
|
|
68
|
+
await connection.commit();
|
|
69
|
+
} catch (error) {
|
|
70
|
+
try {
|
|
71
|
+
await connection.rollback();
|
|
72
|
+
} catch (rollbackError) {
|
|
73
|
+
error.rollbackError = rollbackError;
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
} finally {
|
|
77
|
+
connection.release();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
token: generated.token,
|
|
82
|
+
expires_at: expiresAt,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async requestPasswordReset(email, options = {}) {
|
|
87
|
+
let normalized;
|
|
88
|
+
try {
|
|
89
|
+
normalized = normalizeEmail(email);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const user = await readAuthenticationUserByEmail(this.database, normalized);
|
|
95
|
+
if (!user || user.status !== 'active') return null;
|
|
96
|
+
return this.createForUser(user.id, 'reset', options);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async resetPassword(token, password) {
|
|
100
|
+
if (tokenType(token) !== 'reset') throw invalidActionToken();
|
|
101
|
+
const passwordHash = await hashPassword(password);
|
|
102
|
+
const connection = await this.database.getConnection();
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await connection.beginTransaction();
|
|
106
|
+
const [rows] = await connection.query(
|
|
107
|
+
`SELECT id, user
|
|
108
|
+
FROM yuncms_auth_tokens
|
|
109
|
+
WHERE token_hash = ?
|
|
110
|
+
AND type = ?
|
|
111
|
+
AND used_at IS NULL
|
|
112
|
+
AND expires_at > CURRENT_TIMESTAMP(3)
|
|
113
|
+
LIMIT 1
|
|
114
|
+
FOR UPDATE`,
|
|
115
|
+
[hashToken(token), TOKEN_TYPES.reset],
|
|
116
|
+
);
|
|
117
|
+
const actionToken = rows[0];
|
|
118
|
+
if (!actionToken) throw invalidActionToken();
|
|
119
|
+
|
|
120
|
+
const [result] = await connection.query(
|
|
121
|
+
`UPDATE yuncms_users
|
|
122
|
+
SET password_hash = ?
|
|
123
|
+
WHERE id = ? AND status = 'active'`,
|
|
124
|
+
[passwordHash, actionToken.user],
|
|
125
|
+
);
|
|
126
|
+
if (result.affectedRows !== 1) throw invalidActionToken();
|
|
127
|
+
|
|
128
|
+
await connection.query(
|
|
129
|
+
'UPDATE yuncms_auth_tokens SET used_at = CURRENT_TIMESTAMP(3) WHERE id = ?',
|
|
130
|
+
[actionToken.id],
|
|
131
|
+
);
|
|
132
|
+
await connection.query('DELETE FROM yuncms_sessions WHERE user = ?', [actionToken.user]);
|
|
133
|
+
await connection.query(
|
|
134
|
+
`DELETE FROM yuncms_auth_tokens
|
|
135
|
+
WHERE user = ? AND type = ? AND id <> ?`,
|
|
136
|
+
[actionToken.user, TOKEN_TYPES.reset, actionToken.id],
|
|
137
|
+
);
|
|
138
|
+
await connection.commit();
|
|
139
|
+
return true;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
try {
|
|
142
|
+
await connection.rollback();
|
|
143
|
+
} catch (rollbackError) {
|
|
144
|
+
error.rollbackError = rollbackError;
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
} finally {
|
|
148
|
+
connection.release();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async createEmailVerification(userId, options = {}) {
|
|
153
|
+
const canIssue =
|
|
154
|
+
this.accountability.system === true ||
|
|
155
|
+
this.accountability.admin === true ||
|
|
156
|
+
this.accountability.user === userId;
|
|
157
|
+
if (!canIssue) {
|
|
158
|
+
const error = new Error('Email verification token can only be issued for the authenticated user');
|
|
159
|
+
error.code = 'FORBIDDEN';
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
return this.createForUser(userId, 'verify', options);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async verifyEmail(token) {
|
|
166
|
+
if (tokenType(token) !== 'verify') throw invalidActionToken();
|
|
167
|
+
const connection = await this.database.getConnection();
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await connection.beginTransaction();
|
|
171
|
+
const [rows] = await connection.query(
|
|
172
|
+
`SELECT id, user
|
|
173
|
+
FROM yuncms_auth_tokens
|
|
174
|
+
WHERE token_hash = ?
|
|
175
|
+
AND type = ?
|
|
176
|
+
AND used_at IS NULL
|
|
177
|
+
AND expires_at > CURRENT_TIMESTAMP(3)
|
|
178
|
+
LIMIT 1
|
|
179
|
+
FOR UPDATE`,
|
|
180
|
+
[hashToken(token), TOKEN_TYPES.verify],
|
|
181
|
+
);
|
|
182
|
+
const actionToken = rows[0];
|
|
183
|
+
if (!actionToken) throw invalidActionToken();
|
|
184
|
+
|
|
185
|
+
const [result] = await connection.query(
|
|
186
|
+
`UPDATE yuncms_users
|
|
187
|
+
SET email_verified_at = COALESCE(email_verified_at, CURRENT_TIMESTAMP(3))
|
|
188
|
+
WHERE id = ? AND status = 'active'`,
|
|
189
|
+
[actionToken.user],
|
|
190
|
+
);
|
|
191
|
+
if (result.affectedRows !== 1) throw invalidActionToken();
|
|
192
|
+
|
|
193
|
+
await connection.query(
|
|
194
|
+
'UPDATE yuncms_auth_tokens SET used_at = CURRENT_TIMESTAMP(3) WHERE id = ?',
|
|
195
|
+
[actionToken.id],
|
|
196
|
+
);
|
|
197
|
+
await connection.query(
|
|
198
|
+
`DELETE FROM yuncms_auth_tokens
|
|
199
|
+
WHERE user = ? AND type = ? AND id <> ?`,
|
|
200
|
+
[actionToken.user, TOKEN_TYPES.verify, actionToken.id],
|
|
201
|
+
);
|
|
202
|
+
await connection.commit();
|
|
203
|
+
return true;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
try {
|
|
206
|
+
await connection.rollback();
|
|
207
|
+
} catch (rollbackError) {
|
|
208
|
+
error.rollbackError = rollbackError;
|
|
209
|
+
}
|
|
210
|
+
throw error;
|
|
211
|
+
} finally {
|
|
212
|
+
connection.release();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { requireAccountability } from '../accountability.js';
|
|
2
|
+
|
|
3
|
+
export class BaseService {
|
|
4
|
+
constructor({
|
|
5
|
+
accountability,
|
|
6
|
+
database,
|
|
7
|
+
schema = null,
|
|
8
|
+
emitter = null,
|
|
9
|
+
logger = console,
|
|
10
|
+
storage = null,
|
|
11
|
+
permissionCache = null,
|
|
12
|
+
requestId = null,
|
|
13
|
+
} = {}) {
|
|
14
|
+
requireAccountability(accountability);
|
|
15
|
+
if (!database) throw new Error('Database handle is required');
|
|
16
|
+
|
|
17
|
+
this.accountability = accountability;
|
|
18
|
+
this.database = database;
|
|
19
|
+
this.schema = schema;
|
|
20
|
+
this.emitter = emitter;
|
|
21
|
+
this.logger = logger;
|
|
22
|
+
this.storage = storage;
|
|
23
|
+
this.permissionCache = permissionCache;
|
|
24
|
+
this.requestId = requestId;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { withAdvisoryLock } from '../advisory-lock.js';
|
|
4
|
+
import { assertIdentifier, quoteIdentifier } from '../identifier.js';
|
|
5
|
+
import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
|
|
6
|
+
import { incrementSchemaVersion } from '../schema-version.js';
|
|
7
|
+
import { withConnectionTransaction } from '../transaction.js';
|
|
8
|
+
import { BaseService } from './base-service.js';
|
|
9
|
+
import { assertSchemaManager } from './schema-access.js';
|
|
10
|
+
|
|
11
|
+
const COLLECTION_METADATA_KEYS = new Set(['note', 'singleton', 'hidden', 'metadata']);
|
|
12
|
+
|
|
13
|
+
function assertUserCollectionName(collection) {
|
|
14
|
+
assertIdentifier(collection, 'collection name');
|
|
15
|
+
if (collection.length > 64) throw new Error('Collection name cannot exceed 64 characters');
|
|
16
|
+
if (collection.toLowerCase().startsWith('yuncms_')) {
|
|
17
|
+
const error = new Error('The yuncms_ prefix is reserved for system tables');
|
|
18
|
+
error.code = 'RESERVED_COLLECTION_NAME';
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
return collection;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertCollectionMetadataPatch(patch) {
|
|
25
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
26
|
+
const error = new Error('Collection metadata patch must be an object');
|
|
27
|
+
error.code = 'INVALID_SCHEMA_PAYLOAD';
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
for (const key of Object.keys(patch)) {
|
|
31
|
+
if (!COLLECTION_METADATA_KEYS.has(key)) {
|
|
32
|
+
const error = new Error(`Collection property cannot be updated in V1: ${key}`);
|
|
33
|
+
error.code = 'UNSUPPORTED_SCHEMA_UPDATE';
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (Object.keys(patch).length === 0) {
|
|
38
|
+
const error = new Error('Collection metadata patch cannot be empty');
|
|
39
|
+
error.code = 'INVALID_SCHEMA_PAYLOAD';
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function temporaryDropName() {
|
|
45
|
+
return `_yuncms_drop_${randomUUID().replaceAll('-', '').slice(0, 24)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class CollectionsService extends BaseService {
|
|
49
|
+
async readMany() {
|
|
50
|
+
assertSchemaManager(this.accountability);
|
|
51
|
+
return new SchemaMetadataRepository(this.database).listCollections();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async readOne(collection) {
|
|
55
|
+
assertSchemaManager(this.accountability);
|
|
56
|
+
assertIdentifier(collection, 'collection name');
|
|
57
|
+
return new SchemaMetadataRepository(this.database).readCollection(collection);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async createOne(input = {}) {
|
|
61
|
+
assertSchemaManager(this.accountability);
|
|
62
|
+
const collection = assertUserCollectionName(input.collection);
|
|
63
|
+
const primaryKey = input.primaryKey ?? 'id';
|
|
64
|
+
|
|
65
|
+
if (primaryKey !== 'id') {
|
|
66
|
+
const error = new Error('V1 collection creation currently requires the primary key field to be named id');
|
|
67
|
+
error.code = 'UNSUPPORTED_PRIMARY_KEY';
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
|
|
72
|
+
const metadata = new SchemaMetadataRepository(connection);
|
|
73
|
+
const existing = await metadata.readCollection(collection);
|
|
74
|
+
if (existing) {
|
|
75
|
+
const error = new Error(`Collection already exists: ${collection}`);
|
|
76
|
+
error.code = 'COLLECTION_EXISTS';
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const table = quoteIdentifier(collection, 'collection name');
|
|
81
|
+
let tableCreated = false;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await connection.query(
|
|
85
|
+
`CREATE TABLE ${table} (
|
|
86
|
+
id CHAR(36) NOT NULL PRIMARY KEY
|
|
87
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
|
88
|
+
);
|
|
89
|
+
tableCreated = true;
|
|
90
|
+
|
|
91
|
+
return await withConnectionTransaction(connection, async () => {
|
|
92
|
+
const created = await metadata.createCollection({
|
|
93
|
+
collection,
|
|
94
|
+
primaryKey,
|
|
95
|
+
note: input.note ?? null,
|
|
96
|
+
singleton: input.singleton === true,
|
|
97
|
+
hidden: input.hidden === true,
|
|
98
|
+
metadata: input.metadata ?? null,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await metadata.createField({
|
|
102
|
+
collection,
|
|
103
|
+
field: 'id',
|
|
104
|
+
type: 'uuid',
|
|
105
|
+
required: true,
|
|
106
|
+
readonly: true,
|
|
107
|
+
interface: 'input',
|
|
108
|
+
schemaMetadata: { primaryKey: true, length: 36 },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const schemaVersion = await incrementSchemaVersion(connection);
|
|
112
|
+
return { ...created, schemaVersion };
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const cleanupErrors = [];
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await metadata.deleteCollection(collection);
|
|
119
|
+
} catch (cleanupError) {
|
|
120
|
+
cleanupErrors.push(cleanupError);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (tableCreated) {
|
|
124
|
+
try {
|
|
125
|
+
await connection.query(`DROP TABLE IF EXISTS ${table}`);
|
|
126
|
+
} catch (cleanupError) {
|
|
127
|
+
cleanupErrors.push(cleanupError);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (cleanupErrors.length > 0) {
|
|
132
|
+
error.cleanupErrors = cleanupErrors;
|
|
133
|
+
error.code ||= 'SCHEMA_PARTIAL_FAILURE';
|
|
134
|
+
}
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async updateOne(collection, patch) {
|
|
141
|
+
assertSchemaManager(this.accountability);
|
|
142
|
+
assertIdentifier(collection, 'collection name');
|
|
143
|
+
assertCollectionMetadataPatch(patch);
|
|
144
|
+
|
|
145
|
+
return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
|
|
146
|
+
const metadata = new SchemaMetadataRepository(connection);
|
|
147
|
+
const existing = await metadata.readCollection(collection);
|
|
148
|
+
if (!existing) {
|
|
149
|
+
const error = new Error(`Unknown collection: ${collection}`);
|
|
150
|
+
error.code = 'COLLECTION_NOT_FOUND';
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
if (existing.system) {
|
|
154
|
+
const error = new Error('System collection metadata cannot be changed through the dynamic schema API');
|
|
155
|
+
error.code = 'SYSTEM_SCHEMA_READ_ONLY';
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return withConnectionTransaction(connection, async () => {
|
|
160
|
+
const updated = await metadata.updateCollectionMetadata(collection, patch);
|
|
161
|
+
const schemaVersion = await incrementSchemaVersion(connection);
|
|
162
|
+
return { ...updated, schemaVersion };
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async deleteOne(collection, { destructive = false } = {}) {
|
|
168
|
+
assertSchemaManager(this.accountability);
|
|
169
|
+
assertIdentifier(collection, 'collection name');
|
|
170
|
+
if (destructive !== true) {
|
|
171
|
+
const error = new Error('Collection deletion requires destructive: true');
|
|
172
|
+
error.code = 'DESTRUCTIVE_OPERATION_REQUIRED';
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
|
|
177
|
+
const metadata = new SchemaMetadataRepository(connection);
|
|
178
|
+
const existing = await metadata.readCollection(collection);
|
|
179
|
+
if (!existing) {
|
|
180
|
+
const error = new Error(`Unknown collection: ${collection}`);
|
|
181
|
+
error.code = 'COLLECTION_NOT_FOUND';
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
if (existing.system) {
|
|
185
|
+
const error = new Error('System collections cannot be deleted through the dynamic schema API');
|
|
186
|
+
error.code = 'SYSTEM_SCHEMA_READ_ONLY';
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const relations = await metadata.listRelations();
|
|
191
|
+
const blockingRelations = relations.filter((relation) =>
|
|
192
|
+
relation.many_collection === collection ||
|
|
193
|
+
relation.one_collection === collection ||
|
|
194
|
+
relation.junction_collection === collection);
|
|
195
|
+
if (blockingRelations.length > 0) {
|
|
196
|
+
const error = new Error(`Collection has relations and cannot be deleted: ${collection}`);
|
|
197
|
+
error.code = 'COLLECTION_HAS_RELATIONS';
|
|
198
|
+
error.relations = blockingRelations.map((relation) => ({
|
|
199
|
+
many_collection: relation.many_collection,
|
|
200
|
+
many_field: relation.many_field,
|
|
201
|
+
one_collection: relation.one_collection,
|
|
202
|
+
}));
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const originalTable = quoteIdentifier(collection, 'collection name');
|
|
207
|
+
const tombstoneName = temporaryDropName();
|
|
208
|
+
const tombstoneTable = quoteIdentifier(tombstoneName, 'temporary collection name');
|
|
209
|
+
|
|
210
|
+
await connection.query(`RENAME TABLE ${originalTable} TO ${tombstoneTable}`);
|
|
211
|
+
|
|
212
|
+
let result;
|
|
213
|
+
try {
|
|
214
|
+
result = await withConnectionTransaction(connection, async () => {
|
|
215
|
+
await connection.query('DELETE FROM yuncms_permissions WHERE collection = ?', [collection]);
|
|
216
|
+
const deleted = await metadata.deleteCollection(collection);
|
|
217
|
+
if (deleted !== 1) {
|
|
218
|
+
const error = new Error(`Collection metadata disappeared during delete: ${collection}`);
|
|
219
|
+
error.code = 'SCHEMA_METADATA_DRIFT';
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
const schemaVersion = await incrementSchemaVersion(connection);
|
|
223
|
+
return { deleted: true, collection, schemaVersion };
|
|
224
|
+
});
|
|
225
|
+
} catch (error) {
|
|
226
|
+
try {
|
|
227
|
+
await connection.query(`RENAME TABLE ${tombstoneTable} TO ${originalTable}`);
|
|
228
|
+
} catch (restoreError) {
|
|
229
|
+
error.restoreError = restoreError;
|
|
230
|
+
error.code ||= 'SCHEMA_PARTIAL_FAILURE';
|
|
231
|
+
}
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
await connection.query(`DROP TABLE ${tombstoneTable}`);
|
|
237
|
+
} catch (cleanupError) {
|
|
238
|
+
const error = new Error(`Collection was logically deleted but physical cleanup failed: ${collection}`);
|
|
239
|
+
error.code = 'SCHEMA_PARTIAL_FAILURE';
|
|
240
|
+
error.cleanupError = cleanupError;
|
|
241
|
+
error.cleanupTable = tombstoneName;
|
|
242
|
+
error.logicalDelete = result;
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return result;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ApiTokensService } from './api-tokens-service.js';
|
|
2
|
+
import { AuditService } from './audit-service.js';
|
|
3
|
+
import { AuthService } from './auth-service.js';
|
|
4
|
+
import { AuthTokensService } from './auth-tokens-service.js';
|
|
5
|
+
import { CollectionsService } from './collections-service.js';
|
|
6
|
+
import { FieldsService } from './fields-service.js';
|
|
7
|
+
import { FileReconciliationService } from './file-reconciliation-service.js';
|
|
8
|
+
import { FilesService } from './files-service.js';
|
|
9
|
+
import { ItemsService } from './items-service.js';
|
|
10
|
+
import { PermissionsService } from './permissions-service.js';
|
|
11
|
+
import { RelationsService } from './relations-service.js';
|
|
12
|
+
import { RolesService } from './roles-service.js';
|
|
13
|
+
import { UsersService } from './users-service.js';
|
|
14
|
+
import { createServiceRegistry } from './service-registry.js';
|
|
15
|
+
|
|
16
|
+
export function createCoreServiceRegistry() {
|
|
17
|
+
return createServiceRegistry({
|
|
18
|
+
AuthService,
|
|
19
|
+
AuthTokensService,
|
|
20
|
+
ApiTokensService,
|
|
21
|
+
AuditService,
|
|
22
|
+
ItemsService,
|
|
23
|
+
CollectionsService,
|
|
24
|
+
FieldsService,
|
|
25
|
+
RelationsService,
|
|
26
|
+
UsersService,
|
|
27
|
+
RolesService,
|
|
28
|
+
PermissionsService,
|
|
29
|
+
FilesService,
|
|
30
|
+
FileReconciliationService,
|
|
31
|
+
});
|
|
32
|
+
}
|