@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,179 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { createOpaqueToken, hashToken, tokenType } from '../auth/tokens.js';
4
+ import { BaseService } from './base-service.js';
5
+
6
+ const ACCESS_TTL_MS = 15 * 60 * 1000;
7
+ const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
8
+
9
+ function invalidCredentials() {
10
+ const error = new Error('Invalid or expired authentication token');
11
+ error.code = 'INVALID_CREDENTIALS';
12
+ return error;
13
+ }
14
+
15
+ function addMilliseconds(now, milliseconds) {
16
+ return new Date(now.getTime() + milliseconds);
17
+ }
18
+
19
+ function identityFromRow(row) {
20
+ return {
21
+ user: row.user,
22
+ role: row.role ?? null,
23
+ admin: Boolean(row.role_admin),
24
+ email: row.email,
25
+ session: row.session_id,
26
+ authMethod: 'session',
27
+ };
28
+ }
29
+
30
+ export class SessionsService extends BaseService {
31
+ async createForUser(user, {
32
+ ip = null,
33
+ userAgent = null,
34
+ now = new Date(),
35
+ accessTtlMs = ACCESS_TTL_MS,
36
+ refreshTtlMs = REFRESH_TTL_MS,
37
+ } = {}) {
38
+ const sessionId = randomUUID();
39
+ const access = createOpaqueToken('access');
40
+ const refresh = createOpaqueToken('refresh', { bytes: 48 });
41
+ const accessExpiresAt = addMilliseconds(now, accessTtlMs);
42
+ const refreshExpiresAt = addMilliseconds(now, refreshTtlMs);
43
+
44
+ await this.database.query(
45
+ `INSERT INTO yuncms_sessions
46
+ (id, user, token_hash, access_token_hash, access_expires_at, expires_at, ip, user_agent)
47
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
48
+ [
49
+ sessionId,
50
+ user.id,
51
+ refresh.hash,
52
+ access.hash,
53
+ accessExpiresAt,
54
+ refreshExpiresAt,
55
+ ip,
56
+ userAgent,
57
+ ],
58
+ );
59
+
60
+ return {
61
+ access_token: access.token,
62
+ access_expires_at: accessExpiresAt,
63
+ refresh_token: refresh.token,
64
+ refresh_expires_at: refreshExpiresAt,
65
+ session: sessionId,
66
+ };
67
+ }
68
+
69
+ async authenticateAccessToken(token) {
70
+ if (tokenType(token) !== 'access') throw invalidCredentials();
71
+ const hash = hashToken(token);
72
+ const [rows] = await this.database.query(
73
+ `SELECT s.id AS session_id, s.user, u.email, u.role, u.status,
74
+ r.admin AS role_admin
75
+ FROM yuncms_sessions s
76
+ INNER JOIN yuncms_users u ON u.id = s.user
77
+ LEFT JOIN yuncms_roles r ON r.id = u.role
78
+ WHERE s.access_token_hash = ?
79
+ AND s.access_expires_at > CURRENT_TIMESTAMP(3)
80
+ AND s.expires_at > CURRENT_TIMESTAMP(3)
81
+ AND u.status = 'active'
82
+ LIMIT 1`,
83
+ [hash],
84
+ );
85
+ const row = rows[0];
86
+ if (!row) throw invalidCredentials();
87
+
88
+ await this.database.query(
89
+ `UPDATE yuncms_sessions s
90
+ INNER JOIN yuncms_users u ON u.id = s.user
91
+ SET s.last_used_at = CURRENT_TIMESTAMP(3), u.last_access = CURRENT_TIMESTAMP(3)
92
+ WHERE s.id = ?`,
93
+ [row.session_id],
94
+ );
95
+
96
+ return identityFromRow(row);
97
+ }
98
+
99
+ async rotateRefreshToken(token, {
100
+ now = new Date(),
101
+ accessTtlMs = ACCESS_TTL_MS,
102
+ refreshTtlMs = REFRESH_TTL_MS,
103
+ } = {}) {
104
+ if (tokenType(token) !== 'refresh') throw invalidCredentials();
105
+ const oldHash = hashToken(token);
106
+ const [rows] = await this.database.query(
107
+ `SELECT s.id AS session_id, s.user, u.email, u.role, u.status,
108
+ r.admin AS role_admin
109
+ FROM yuncms_sessions s
110
+ INNER JOIN yuncms_users u ON u.id = s.user
111
+ LEFT JOIN yuncms_roles r ON r.id = u.role
112
+ WHERE s.token_hash = ?
113
+ AND s.expires_at > CURRENT_TIMESTAMP(3)
114
+ AND u.status = 'active'
115
+ LIMIT 1`,
116
+ [oldHash],
117
+ );
118
+ const row = rows[0];
119
+ if (!row) throw invalidCredentials();
120
+
121
+ const access = createOpaqueToken('access');
122
+ const refresh = createOpaqueToken('refresh', { bytes: 48 });
123
+ const accessExpiresAt = addMilliseconds(now, accessTtlMs);
124
+ const refreshExpiresAt = addMilliseconds(now, refreshTtlMs);
125
+ const [result] = await this.database.query(
126
+ `UPDATE yuncms_sessions
127
+ SET token_hash = ?, access_token_hash = ?, access_expires_at = ?, expires_at = ?,
128
+ last_used_at = CURRENT_TIMESTAMP(3)
129
+ WHERE id = ? AND token_hash = ?`,
130
+ [
131
+ refresh.hash,
132
+ access.hash,
133
+ accessExpiresAt,
134
+ refreshExpiresAt,
135
+ row.session_id,
136
+ oldHash,
137
+ ],
138
+ );
139
+
140
+ if (result.affectedRows !== 1) throw invalidCredentials();
141
+
142
+ return {
143
+ ...identityFromRow(row),
144
+ access_token: access.token,
145
+ access_expires_at: accessExpiresAt,
146
+ refresh_token: refresh.token,
147
+ refresh_expires_at: refreshExpiresAt,
148
+ };
149
+ }
150
+
151
+ async revokeByAccessToken(token) {
152
+ if (tokenType(token) !== 'access') throw invalidCredentials();
153
+ const [result] = await this.database.query(
154
+ 'DELETE FROM yuncms_sessions WHERE access_token_hash = ?',
155
+ [hashToken(token)],
156
+ );
157
+ return result.affectedRows > 0;
158
+ }
159
+
160
+ async revokeAllForUser(userId) {
161
+ const self = this.accountability.user === userId;
162
+ if (!self && this.accountability.admin !== true && this.accountability.system !== true) {
163
+ const error = new Error('Session revocation requires self or administrator accountability');
164
+ error.code = 'FORBIDDEN';
165
+ throw error;
166
+ }
167
+
168
+ const [result] = await this.database.query(
169
+ 'DELETE FROM yuncms_sessions WHERE user = ?',
170
+ [userId],
171
+ );
172
+ return result.affectedRows;
173
+ }
174
+ }
175
+
176
+ export const SESSION_DEFAULTS = Object.freeze({
177
+ accessTtlMs: ACCESS_TTL_MS,
178
+ refreshTtlMs: REFRESH_TTL_MS,
179
+ });
@@ -0,0 +1,215 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { hashPassword } from '../auth/password.js';
4
+ import { withTransaction } from '../transaction.js';
5
+ import { BaseService } from './base-service.js';
6
+
7
+ const USER_STATUSES = new Set(['active', 'suspended', 'disabled']);
8
+ const USER_UPDATE_KEYS = new Set(['email', 'role', 'status']);
9
+
10
+ function normalizeEmail(email) {
11
+ if (typeof email !== 'string') {
12
+ const error = new Error('Email is required');
13
+ error.code = 'INVALID_PAYLOAD';
14
+ throw error;
15
+ }
16
+ const normalized = email.trim().toLowerCase();
17
+ if (!normalized || normalized.length > 191 || !normalized.includes('@')) {
18
+ const error = new Error('Email is invalid');
19
+ error.code = 'INVALID_PAYLOAD';
20
+ throw error;
21
+ }
22
+ return normalized;
23
+ }
24
+
25
+ function assertUserManager(accountability) {
26
+ if (accountability.admin === true || accountability.system === true) return;
27
+ const error = new Error('User management requires administrator accountability');
28
+ error.code = 'FORBIDDEN';
29
+ throw error;
30
+ }
31
+
32
+ function assertStatus(status) {
33
+ if (!USER_STATUSES.has(status)) {
34
+ const error = new Error(`Unsupported user status: ${status}`);
35
+ error.code = 'INVALID_PAYLOAD';
36
+ throw error;
37
+ }
38
+ return status;
39
+ }
40
+
41
+ async function assertRoleExists(database, role) {
42
+ if (role == null) return;
43
+ const [roleRows] = await database.query('SELECT id FROM yuncms_roles WHERE id = ? LIMIT 1', [role]);
44
+ if (!roleRows[0]) {
45
+ const error = new Error(`Unknown role: ${role}`);
46
+ error.code = 'ROLE_NOT_FOUND';
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ export class UsersService extends BaseService {
52
+ async readMany() {
53
+ assertUserManager(this.accountability);
54
+ const [rows] = await this.database.query(
55
+ `SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
56
+ FROM yuncms_users
57
+ ORDER BY email ASC`,
58
+ );
59
+ return rows;
60
+ }
61
+
62
+ async readOne(id) {
63
+ 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;
74
+ }
75
+
76
+ async createOne(input = {}) {
77
+ assertUserManager(this.accountability);
78
+ const email = normalizeEmail(input.email);
79
+ const status = assertStatus(input.status ?? 'active');
80
+ await assertRoleExists(this.database, input.role ?? null);
81
+
82
+ const passwordHash = await hashPassword(input.password);
83
+ const id = randomUUID();
84
+ await this.database.query(
85
+ `INSERT INTO yuncms_users (id, email, password_hash, role, status, email_verified_at)
86
+ VALUES (?, ?, ?, ?, ?, ?)`,
87
+ [
88
+ id,
89
+ email,
90
+ passwordHash,
91
+ input.role ?? null,
92
+ status,
93
+ input.emailVerified === true ? new Date() : null,
94
+ ],
95
+ );
96
+
97
+ return this.readOne(id);
98
+ }
99
+
100
+ async updateOne(id, patch = {}) {
101
+ assertUserManager(this.accountability);
102
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
103
+ const error = new Error('User patch must be an object');
104
+ error.code = 'INVALID_PAYLOAD';
105
+ throw error;
106
+ }
107
+ const keys = Object.keys(patch);
108
+ if (keys.length === 0 || keys.some((key) => !USER_UPDATE_KEYS.has(key))) {
109
+ const error = new Error('User update supports email, role and status only');
110
+ error.code = 'INVALID_PAYLOAD';
111
+ throw error;
112
+ }
113
+
114
+ if (Object.hasOwn(patch, 'status')) {
115
+ assertStatus(patch.status);
116
+ if (this.accountability.user === id && patch.status !== 'active') {
117
+ const error = new Error('An administrator cannot suspend or disable their own active session');
118
+ error.code = 'SELF_ADMIN_MUTATION_FORBIDDEN';
119
+ throw error;
120
+ }
121
+ }
122
+ if (Object.hasOwn(patch, 'role')) await assertRoleExists(this.database, patch.role);
123
+
124
+ return withTransaction(this.database, async (connection) => {
125
+ const assignments = [];
126
+ const params = [];
127
+
128
+ if (Object.hasOwn(patch, 'email')) {
129
+ assignments.push('email = ?');
130
+ params.push(normalizeEmail(patch.email));
131
+ }
132
+ if (Object.hasOwn(patch, 'role')) {
133
+ assignments.push('role = ?');
134
+ params.push(patch.role ?? null);
135
+ }
136
+ if (Object.hasOwn(patch, 'status')) {
137
+ assignments.push('status = ?');
138
+ params.push(patch.status);
139
+ }
140
+
141
+ params.push(id);
142
+ const [result] = await connection.query(
143
+ `UPDATE yuncms_users SET ${assignments.join(', ')} WHERE id = ?`,
144
+ params,
145
+ );
146
+ if (result.affectedRows !== 1) {
147
+ const error = new Error(`Unknown user: ${id}`);
148
+ error.code = 'USER_NOT_FOUND';
149
+ throw error;
150
+ }
151
+
152
+ if (Object.hasOwn(patch, 'status') && patch.status !== 'active') {
153
+ await connection.query('DELETE FROM yuncms_sessions WHERE user = ?', [id]);
154
+ }
155
+
156
+ const [rows] = await connection.query(
157
+ `SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
158
+ FROM yuncms_users WHERE id = ? LIMIT 1`,
159
+ [id],
160
+ );
161
+ return rows[0] ?? null;
162
+ });
163
+ }
164
+
165
+ async deleteOne(id) {
166
+ assertUserManager(this.accountability);
167
+ if (this.accountability.user === id) {
168
+ const error = new Error('An administrator cannot delete their own user account');
169
+ error.code = 'SELF_ADMIN_MUTATION_FORBIDDEN';
170
+ throw error;
171
+ }
172
+ const [result] = await this.database.query('DELETE FROM yuncms_users WHERE id = ?', [id]);
173
+ if (result.affectedRows !== 1) {
174
+ const error = new Error(`Unknown user: ${id}`);
175
+ error.code = 'USER_NOT_FOUND';
176
+ throw error;
177
+ }
178
+ return true;
179
+ }
180
+
181
+ async updatePassword(id, password) {
182
+ const self = this.accountability.user === id;
183
+ if (!self) assertUserManager(this.accountability);
184
+
185
+ const passwordHash = await hashPassword(password);
186
+ const connection = await this.database.getConnection();
187
+
188
+ try {
189
+ await connection.beginTransaction();
190
+ const [result] = await connection.query(
191
+ 'UPDATE yuncms_users SET password_hash = ? WHERE id = ?',
192
+ [passwordHash, id],
193
+ );
194
+ if (result.affectedRows !== 1) {
195
+ const error = new Error(`Unknown user: ${id}`);
196
+ error.code = 'USER_NOT_FOUND';
197
+ throw error;
198
+ }
199
+ await connection.query('DELETE FROM yuncms_sessions WHERE user = ?', [id]);
200
+ await connection.commit();
201
+ return true;
202
+ } catch (error) {
203
+ try {
204
+ await connection.rollback();
205
+ } catch (rollbackError) {
206
+ error.rollbackError = rollbackError;
207
+ }
208
+ throw error;
209
+ } finally {
210
+ connection.release();
211
+ }
212
+ }
213
+ }
214
+
215
+ export { normalizeEmail };
package/src/setup.js ADDED
@@ -0,0 +1,66 @@
1
+ import { createSystemAccountability } from './accountability.js';
2
+ import { withTransaction } from './transaction.js';
3
+ import { RolesService } from './services/roles-service.js';
4
+ import { UsersService } from './services/users-service.js';
5
+
6
+ export async function findExistingAdmin(database) {
7
+ if (!database) throw new Error('Database handle is required');
8
+ const [rows] = await database.query(
9
+ `SELECT u.id, u.email, u.role
10
+ FROM yuncms_users u
11
+ INNER JOIN yuncms_roles r ON r.id = u.role
12
+ WHERE r.admin = 1
13
+ ORDER BY u.created_at ASC
14
+ LIMIT 1`,
15
+ );
16
+ return rows[0] ?? null;
17
+ }
18
+
19
+ export async function createInitialAdmin(pool, { email, password } = {}) {
20
+ if (!pool) throw new Error('Database pool is required');
21
+ const accountability = createSystemAccountability();
22
+
23
+ return withTransaction(pool, async (connection) => {
24
+ const existingAdmin = await findExistingAdmin(connection);
25
+ if (existingAdmin) {
26
+ const error = new Error(`An administrator user already exists: ${existingAdmin.email}`);
27
+ error.code = 'INITIAL_ADMIN_EXISTS';
28
+ error.admin = existingAdmin;
29
+ throw error;
30
+ }
31
+
32
+ const [adminRoles] = await connection.query(
33
+ `SELECT id, name
34
+ FROM yuncms_roles
35
+ WHERE admin = 1
36
+ ORDER BY created_at ASC
37
+ LIMIT 1`,
38
+ );
39
+
40
+ let roleId = adminRoles[0]?.id ?? null;
41
+ if (!roleId) {
42
+ const roles = new RolesService({ accountability, database: connection });
43
+ const role = await roles.createOne({
44
+ name: 'Administrator',
45
+ description: 'Full YunCMS administrator access',
46
+ admin: true,
47
+ });
48
+ roleId = role.id;
49
+ }
50
+
51
+ const users = new UsersService({ accountability, database: connection });
52
+ const user = await users.createOne({
53
+ email,
54
+ password,
55
+ role: roleId,
56
+ status: 'active',
57
+ emailVerified: true,
58
+ });
59
+
60
+ return {
61
+ id: user.id,
62
+ email: user.email,
63
+ role: roleId,
64
+ };
65
+ });
66
+ }
@@ -0,0 +1,105 @@
1
+ import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises';
2
+ import { isAbsolute, relative, resolve } from 'node:path';
3
+
4
+ const STORAGE_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,190}$/;
5
+
6
+ function storageError(code, message) {
7
+ const error = new Error(message);
8
+ error.code = code;
9
+ return error;
10
+ }
11
+
12
+ function assertStorageKey(key) {
13
+ if (typeof key !== 'string' || !STORAGE_KEY.test(key) || key.includes('..')) {
14
+ throw storageError('INVALID_STORAGE_KEY', 'Storage key contains unsupported path characters');
15
+ }
16
+ return key;
17
+ }
18
+
19
+ export class LocalStorageDriver {
20
+ constructor({ root }) {
21
+ if (!root || typeof root !== 'string') throw new Error('Local storage root is required');
22
+ this.root = resolve(root);
23
+ }
24
+
25
+ pathFor(key) {
26
+ const safeKey = assertStorageKey(key);
27
+ const path = resolve(this.root, safeKey);
28
+ const fromRoot = relative(this.root, path);
29
+ if (!fromRoot || fromRoot.startsWith('..') || isAbsolute(fromRoot)) {
30
+ throw storageError('INVALID_STORAGE_KEY', 'Storage key escaped the configured root');
31
+ }
32
+ return path;
33
+ }
34
+
35
+ async put(key, contents) {
36
+ const path = this.pathFor(key);
37
+ if (!Buffer.isBuffer(contents) && !(contents instanceof Uint8Array)) {
38
+ throw storageError('INVALID_FILE_CONTENT', 'Local storage put requires Buffer or Uint8Array content');
39
+ }
40
+ await mkdir(this.root, { recursive: true });
41
+ await writeFile(path, contents, { flag: 'wx' });
42
+ return { key, size: contents.byteLength };
43
+ }
44
+
45
+ async get(key) {
46
+ return readFile(this.pathFor(key));
47
+ }
48
+
49
+ async stat(key) {
50
+ try {
51
+ const info = await stat(this.pathFor(key));
52
+ return {
53
+ key,
54
+ size: info.size,
55
+ modifiedAt: info.mtime,
56
+ };
57
+ } catch (error) {
58
+ if (error?.code === 'ENOENT') return null;
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ async list() {
64
+ let entries;
65
+ try {
66
+ entries = await readdir(this.root, { withFileTypes: true });
67
+ } catch (error) {
68
+ if (error?.code === 'ENOENT') return [];
69
+ throw error;
70
+ }
71
+
72
+ const objects = [];
73
+ for (const entry of entries) {
74
+ if (!entry.isFile()) continue;
75
+ try {
76
+ const key = assertStorageKey(entry.name);
77
+ const info = await stat(this.pathFor(key));
78
+ objects.push({
79
+ key,
80
+ size: info.size,
81
+ modifiedAt: info.mtime,
82
+ });
83
+ } catch (error) {
84
+ if (error?.code !== 'INVALID_STORAGE_KEY') throw error;
85
+ }
86
+ }
87
+ return objects;
88
+ }
89
+
90
+ async delete(key) {
91
+ try {
92
+ await unlink(this.pathFor(key));
93
+ return true;
94
+ } catch (error) {
95
+ if (error?.code === 'ENOENT') return false;
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ async getSignedUrl() {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ export { assertStorageKey };
@@ -0,0 +1,150 @@
1
+ import {
2
+ DeleteObjectCommand,
3
+ GetObjectCommand,
4
+ HeadObjectCommand,
5
+ ListObjectsV2Command,
6
+ PutObjectCommand,
7
+ S3Client,
8
+ } from '@aws-sdk/client-s3';
9
+
10
+ import { assertStorageKey } from './local-storage-driver.js';
11
+
12
+ async function bodyToBuffer(body) {
13
+ if (!body) return Buffer.alloc(0);
14
+ if (typeof body.transformToByteArray === 'function') {
15
+ return Buffer.from(await body.transformToByteArray());
16
+ }
17
+ if (Symbol.asyncIterator in Object(body)) {
18
+ const chunks = [];
19
+ for await (const chunk of body) chunks.push(Buffer.from(chunk));
20
+ return Buffer.concat(chunks);
21
+ }
22
+ throw new Error('Unsupported S3 response body type');
23
+ }
24
+
25
+ function credentialsFromConfig({ accessKeyId, secretAccessKey }) {
26
+ if (!accessKeyId && !secretAccessKey) return undefined;
27
+ if (!accessKeyId || !secretAccessKey) {
28
+ throw new Error('S3 access key id and secret access key must be configured together');
29
+ }
30
+ return { accessKeyId, secretAccessKey };
31
+ }
32
+
33
+ export class S3StorageDriver {
34
+ constructor({
35
+ bucket,
36
+ region = 'us-east-1',
37
+ endpoint = undefined,
38
+ accessKeyId = undefined,
39
+ secretAccessKey = undefined,
40
+ forcePathStyle = false,
41
+ client = null,
42
+ } = {}) {
43
+ if (!bucket || typeof bucket !== 'string') throw new Error('S3 bucket is required');
44
+ this.bucket = bucket;
45
+ this.client = client ?? new S3Client({
46
+ region,
47
+ ...(endpoint ? { endpoint } : {}),
48
+ ...(credentialsFromConfig({ accessKeyId, secretAccessKey })
49
+ ? { credentials: credentialsFromConfig({ accessKeyId, secretAccessKey }) }
50
+ : {}),
51
+ forcePathStyle: forcePathStyle === true,
52
+ });
53
+ }
54
+
55
+ async put(key, contents) {
56
+ const safeKey = assertStorageKey(key);
57
+ if (!Buffer.isBuffer(contents) && !(contents instanceof Uint8Array)) {
58
+ const error = new Error('S3 storage put requires Buffer or Uint8Array content');
59
+ error.code = 'INVALID_FILE_CONTENT';
60
+ throw error;
61
+ }
62
+ await this.client.send(new PutObjectCommand({
63
+ Bucket: this.bucket,
64
+ Key: safeKey,
65
+ Body: contents,
66
+ }));
67
+ return { key: safeKey, size: contents.byteLength };
68
+ }
69
+
70
+ async get(key) {
71
+ const safeKey = assertStorageKey(key);
72
+ const response = await this.client.send(new GetObjectCommand({
73
+ Bucket: this.bucket,
74
+ Key: safeKey,
75
+ }));
76
+ return bodyToBuffer(response.Body);
77
+ }
78
+
79
+ async stat(key) {
80
+ const safeKey = assertStorageKey(key);
81
+ try {
82
+ const response = await this.client.send(new HeadObjectCommand({
83
+ Bucket: this.bucket,
84
+ Key: safeKey,
85
+ }));
86
+ return {
87
+ key: safeKey,
88
+ size: Number(response.ContentLength ?? 0),
89
+ modifiedAt: response.LastModified ?? null,
90
+ etag: response.ETag ?? null,
91
+ };
92
+ } catch (error) {
93
+ if (error?.$metadata?.httpStatusCode === 404 || error?.name === 'NotFound' || error?.name === 'NoSuchKey') {
94
+ return null;
95
+ }
96
+ throw error;
97
+ }
98
+ }
99
+
100
+ async list() {
101
+ const objects = [];
102
+ let continuationToken;
103
+
104
+ do {
105
+ const response = await this.client.send(new ListObjectsV2Command({
106
+ Bucket: this.bucket,
107
+ ...(continuationToken ? { ContinuationToken: continuationToken } : {}),
108
+ }));
109
+
110
+ for (const object of response.Contents ?? []) {
111
+ if (!object.Key) continue;
112
+ try {
113
+ const key = assertStorageKey(object.Key);
114
+ objects.push({
115
+ key,
116
+ size: Number(object.Size ?? 0),
117
+ modifiedAt: object.LastModified ?? null,
118
+ etag: object.ETag ?? null,
119
+ });
120
+ } catch (error) {
121
+ if (error?.code !== 'INVALID_STORAGE_KEY') throw error;
122
+ }
123
+ }
124
+
125
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
126
+ if (response.IsTruncated && !continuationToken) {
127
+ const error = new Error('S3 inventory response was truncated without a continuation token');
128
+ error.code = 'STORAGE_INVENTORY_FAILED';
129
+ throw error;
130
+ }
131
+ } while (continuationToken);
132
+
133
+ return objects;
134
+ }
135
+
136
+ async delete(key) {
137
+ const safeKey = assertStorageKey(key);
138
+ await this.client.send(new DeleteObjectCommand({
139
+ Bucket: this.bucket,
140
+ Key: safeKey,
141
+ }));
142
+ return true;
143
+ }
144
+
145
+ async getSignedUrl() {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ export { bodyToBuffer, credentialsFromConfig };