s3db.js 13.4.0 → 13.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3db.js",
3
- "version": "13.4.0",
3
+ "version": "13.5.1",
4
4
  "description": "Use AWS S3, the world's most reliable document storage, as a database with this ORM.",
5
5
  "main": "dist/s3db.cjs.js",
6
6
  "module": "dist/s3db.es.js",
@@ -87,7 +87,7 @@
87
87
  "@aws-sdk/client-sqs": "^3.0.0",
88
88
  "@google-cloud/bigquery": "^7.0.0",
89
89
  "@hono/node-server": "^1.0.0",
90
- "@hono/swagger-ui": "^0.5.0",
90
+ "@hono/swagger-ui": "^0.5.2",
91
91
  "@libsql/client": "^0.14.0",
92
92
  "@planetscale/database": "^1.0.0",
93
93
  "@tensorflow/tfjs-node": "^4.0.0",
@@ -137,7 +137,7 @@
137
137
  "@babel/preset-env": "^7.28.3",
138
138
  "@google-cloud/bigquery": "^7.9.4",
139
139
  "@hono/node-server": "^1.13.7",
140
- "@hono/swagger-ui": "^0.5.3",
140
+ "@hono/swagger-ui": "^0.5.2",
141
141
  "@libsql/client": "^0.14.0",
142
142
  "@planetscale/database": "^1.19.0",
143
143
  "@rollup/plugin-commonjs": "^28.0.8",
@@ -1091,7 +1091,7 @@ export class Database extends EventEmitter {
1091
1091
  if (!existingVersionData || existingVersionData.hash !== newHash) {
1092
1092
  await this.uploadMetadataFile();
1093
1093
  }
1094
- this.emit("s3db.resourceUpdated", name);
1094
+ this.emit("db:resource-updated", name);
1095
1095
  return existingResource;
1096
1096
  }
1097
1097
  const existingMetadata = this.savedMetadata?.resources?.[name];
@@ -1131,7 +1131,7 @@ export class Database extends EventEmitter {
1131
1131
  }
1132
1132
 
1133
1133
  await this.uploadMetadataFile();
1134
- this.emit("s3db.resourceCreated", name);
1134
+ this.emit("db:resource-created", name);
1135
1135
  return resource;
1136
1136
  }
1137
1137
 
@@ -67,7 +67,9 @@ async function verifyPassword(inputPassword, storedPassword, passphrase) {
67
67
  * Create Basic Auth middleware
68
68
  * @param {Object} options - Basic Auth options
69
69
  * @param {string} options.realm - Authentication realm (default: 'API Access')
70
- * @param {Object} options.usersResource - Users resource for credential validation
70
+ * @param {Object} options.authResource - Resource for credential validation
71
+ * @param {string} options.usernameField - Field name for username (default: 'email')
72
+ * @param {string} options.passwordField - Field name for password (default: 'password')
71
73
  * @param {string} options.passphrase - Passphrase for password decryption
72
74
  * @param {boolean} options.optional - If true, allows requests without auth
73
75
  * @returns {Function} Hono middleware
@@ -75,13 +77,15 @@ async function verifyPassword(inputPassword, storedPassword, passphrase) {
75
77
  export function basicAuth(options = {}) {
76
78
  const {
77
79
  realm = 'API Access',
78
- usersResource,
80
+ authResource,
81
+ usernameField = 'email',
82
+ passwordField = 'password',
79
83
  passphrase = 'secret',
80
84
  optional = false
81
85
  } = options;
82
86
 
83
- if (!usersResource) {
84
- throw new Error('usersResource is required for Basic authentication');
87
+ if (!authResource) {
88
+ throw new Error('authResource is required for Basic authentication');
85
89
  }
86
90
 
87
91
  return async (c, next) => {
@@ -107,9 +111,10 @@ export function basicAuth(options = {}) {
107
111
 
108
112
  const { username, password } = credentials;
109
113
 
110
- // Query user by username
114
+ // Query user by configured username field
111
115
  try {
112
- const users = await usersResource.query({ username });
116
+ const queryFilter = { [usernameField]: username };
117
+ const users = await authResource.query(queryFilter);
113
118
 
114
119
  if (!users || users.length === 0) {
115
120
  c.header('WWW-Authenticate', `Basic realm="${realm}"`);
@@ -119,14 +124,17 @@ export function basicAuth(options = {}) {
119
124
 
120
125
  const user = users[0];
121
126
 
122
- if (!user.active) {
127
+ // Check if user is active (if field exists)
128
+ if (user.active !== undefined && !user.active) {
123
129
  c.header('WWW-Authenticate', `Basic realm="${realm}"`);
124
130
  const response = unauthorized('User account is inactive');
125
131
  return c.json(response, response._status);
126
132
  }
127
133
 
128
- // Verify password
129
- const isValid = await verifyPassword(password, user.password, passphrase);
134
+ // Verify password using configured password field
135
+ // Schema handles encryption/decryption for 'secret' field types
136
+ const storedPassword = user[passwordField];
137
+ const isValid = storedPassword === password;
130
138
 
131
139
  if (!isValid) {
132
140
  c.header('WWW-Authenticate', `Basic realm="${realm}"`);
@@ -56,6 +56,10 @@ export class ApiPlugin extends Plugin {
56
56
  host: options.host || '0.0.0.0',
57
57
  verbose: options.verbose || false,
58
58
 
59
+ // Version prefix configuration (global default)
60
+ // Can be: true (use resource version), false (no prefix - DEFAULT), or string (custom prefix like 'api/v1')
61
+ versionPrefix: options.versionPrefix !== undefined ? options.versionPrefix : false,
62
+
59
63
  docs: {
60
64
  enabled: options.docs?.enabled !== false && options.docsEnabled !== false, // Enable by default
61
65
  ui: options.docs?.ui || 'redoc', // 'swagger' or 'redoc' (redoc is prettier!)
@@ -64,26 +68,27 @@ export class ApiPlugin extends Plugin {
64
68
  description: options.docs?.description || options.apiDescription || 'Auto-generated REST API for s3db.js resources'
65
69
  },
66
70
 
67
- // Authentication configuration
68
- auth: {
69
- jwt: {
70
- enabled: options.auth?.jwt?.enabled || false,
71
- secret: options.auth?.jwt?.secret || null,
72
- expiresIn: options.auth?.jwt?.expiresIn || '7d'
73
- },
74
- apiKey: {
75
- enabled: options.auth?.apiKey?.enabled || false,
76
- headerName: options.auth?.apiKey?.headerName || 'X-API-Key'
77
- },
78
- basic: {
79
- enabled: options.auth?.basic?.enabled || false,
80
- realm: options.auth?.basic?.realm || 'API Access'
81
- }
71
+ // Authentication configuration (driver-based)
72
+ auth: options.auth ? {
73
+ driver: options.auth.driver || null, // 'jwt' or 'basic'
74
+ resource: options.auth.resource || 'users', // Resource that manages auth
75
+ usernameField: options.auth.usernameField || 'email', // Default: email
76
+ passwordField: options.auth.passwordField || 'password', // Default: password
77
+ config: options.auth.config || {} // Driver-specific config
78
+ } : {
79
+ driver: null,
80
+ resource: 'users',
81
+ usernameField: 'email',
82
+ passwordField: 'password',
83
+ config: {}
82
84
  },
83
85
 
84
86
  // Resource configuration
85
87
  resources: options.resources || {},
86
88
 
89
+ // Custom routes (plugin-level)
90
+ routes: options.routes || {},
91
+
87
92
  // CORS configuration
88
93
  cors: {
89
94
  enabled: options.cors?.enabled || false,
@@ -176,10 +181,8 @@ export class ApiPlugin extends Plugin {
176
181
  throw err;
177
182
  }
178
183
 
179
- // Create users resource if authentication is enabled
180
- const authEnabled = this.config.auth.jwt.enabled ||
181
- this.config.auth.apiKey.enabled ||
182
- this.config.auth.basic.enabled;
184
+ // Create users resource if authentication driver is configured
185
+ const authEnabled = this.config.auth.driver !== null;
183
186
 
184
187
  if (authEnabled) {
185
188
  await this._createUsersResource();
@@ -467,6 +470,7 @@ export class ApiPlugin extends Plugin {
467
470
  host: this.config.host,
468
471
  database: this.database,
469
472
  resources: this.config.resources,
473
+ routes: this.config.routes,
470
474
  middlewares: this.compiledMiddlewares,
471
475
  verbose: this.config.verbose,
472
476
  auth: this.config.auth,
@@ -14,13 +14,16 @@ import tryFn from '../../../concerns/try-fn.js';
14
14
 
15
15
  /**
16
16
  * Create authentication routes
17
- * @param {Object} usersResource - s3db.js users resource
17
+ * @param {Object} authResource - s3db.js resource that manages authentication
18
18
  * @param {Object} config - Auth configuration
19
19
  * @returns {Hono} Hono app with auth routes
20
20
  */
21
- export function createAuthRoutes(usersResource, config = {}) {
21
+ export function createAuthRoutes(authResource, config = {}) {
22
22
  const app = new Hono();
23
23
  const {
24
+ driver, // 'jwt' or 'basic'
25
+ usernameField = 'email', // Field name for username (default: 'email')
26
+ passwordField = 'password', // Field name for password (default: 'password')
24
27
  jwtSecret,
25
28
  jwtExpiresIn = '7d',
26
29
  passphrase = 'secret',
@@ -31,134 +34,152 @@ export function createAuthRoutes(usersResource, config = {}) {
31
34
  if (allowRegistration) {
32
35
  app.post('/register', asyncHandler(async (c) => {
33
36
  const data = await c.req.json();
34
- const { username, password, email, role = 'user' } = data;
37
+ const username = data[usernameField];
38
+ const password = data[passwordField];
39
+ const role = data.role || 'user';
35
40
 
36
41
  // Validate input
37
42
  if (!username || !password) {
38
43
  const response = formatter.validationError([
39
- { field: 'username', message: 'Username is required' },
40
- { field: 'password', message: 'Password is required' }
44
+ { field: usernameField, message: `${usernameField} is required` },
45
+ { field: passwordField, message: `${passwordField} is required` }
41
46
  ]);
42
47
  return c.json(response, response._status);
43
48
  }
44
49
 
45
50
  if (password.length < 8) {
46
51
  const response = formatter.validationError([
47
- { field: 'password', message: 'Password must be at least 8 characters' }
52
+ { field: passwordField, message: 'Password must be at least 8 characters' }
48
53
  ]);
49
54
  return c.json(response, response._status);
50
55
  }
51
56
 
52
57
  // Check if username already exists
53
- const existing = await usersResource.query({ username });
58
+ const queryFilter = { [usernameField]: username };
59
+ const existing = await authResource.query(queryFilter);
54
60
  if (existing && existing.length > 0) {
55
- const response = formatter.error('Username already exists', {
61
+ const response = formatter.error(`${usernameField} already exists`, {
56
62
  status: 409,
57
63
  code: 'CONFLICT'
58
64
  });
59
65
  return c.json(response, response._status);
60
66
  }
61
67
 
62
- // Create user
63
- const user = await usersResource.insert({
64
- username,
65
- password, // Will be auto-encrypted by schema (secret field)
66
- email,
67
- role,
68
- active: true,
69
- apiKey: generateApiKey(),
70
- createdAt: new Date().toISOString()
71
- });
68
+ // Create user with dynamic fields
69
+ // Only include fields from request + required auth fields
70
+ const userData = {
71
+ ...data, // Include all fields from request first
72
+ [usernameField]: username, // Override to ensure correct value
73
+ [passwordField]: password // Will be auto-encrypted by schema (secret field)
74
+ };
75
+
76
+ // Add optional fields only if not provided
77
+ if (!userData.role) {
78
+ userData.role = role;
79
+ }
80
+ if (userData.active === undefined) {
81
+ userData.active = true;
82
+ }
72
83
 
73
- // Generate JWT token
84
+ const user = await authResource.insert(userData);
85
+
86
+ // Generate JWT token (only for JWT driver)
74
87
  let token = null;
75
- if (jwtSecret) {
88
+ if (driver === 'jwt' && jwtSecret) {
76
89
  token = createToken(
77
- { userId: user.id, username: user.username, role: user.role },
90
+ {
91
+ userId: user.id,
92
+ [usernameField]: user[usernameField],
93
+ role: user.role
94
+ },
78
95
  jwtSecret,
79
96
  jwtExpiresIn
80
97
  );
81
98
  }
82
99
 
83
100
  // Remove sensitive data from response
84
- const { password: _, ...userWithoutPassword } = user;
101
+ const { [passwordField]: _, ...userWithoutPassword } = user;
85
102
 
86
103
  const response = formatter.created({
87
104
  user: userWithoutPassword,
88
- token
105
+ ...(token && { token }) // Only include token if JWT driver
89
106
  }, `/auth/users/${user.id}`);
90
107
 
91
108
  return c.json(response, response._status);
92
109
  }));
93
110
  }
94
111
 
95
- // POST /auth/login - Login with username/password
96
- app.post('/login', asyncHandler(async (c) => {
97
- const data = await c.req.json();
98
- const { username, password } = data;
99
-
100
- // Validate input
101
- if (!username || !password) {
102
- const response = formatter.unauthorized('Username and password are required');
103
- return c.json(response, response._status);
104
- }
112
+ // POST /auth/login - Login with username/password (JWT driver only)
113
+ if (driver === 'jwt') {
114
+ app.post('/login', asyncHandler(async (c) => {
115
+ const data = await c.req.json();
116
+ const username = data[usernameField];
117
+ const password = data[passwordField];
105
118
 
106
- // Find user
107
- const users = await usersResource.query({ username });
108
- if (!users || users.length === 0) {
109
- const response = formatter.unauthorized('Invalid credentials');
110
- return c.json(response, response._status);
111
- }
119
+ // Validate input
120
+ if (!username || !password) {
121
+ const response = formatter.unauthorized(`${usernameField} and ${passwordField} are required`);
122
+ return c.json(response, response._status);
123
+ }
112
124
 
113
- const user = users[0];
125
+ // Find user by username field
126
+ const queryFilter = { [usernameField]: username };
127
+ const users = await authResource.query(queryFilter);
128
+ if (!users || users.length === 0) {
129
+ const response = formatter.unauthorized('Invalid credentials');
130
+ return c.json(response, response._status);
131
+ }
114
132
 
115
- if (!user.active) {
116
- const response = formatter.unauthorized('User account is inactive');
117
- return c.json(response, response._status);
118
- }
133
+ const user = users[0];
119
134
 
120
- // Verify password (decrypt and compare)
121
- // Note: In production, use proper password hashing (bcrypt, argon2)
122
- const [ok, err, decrypted] = await tryFn(() =>
123
- user.password // Password is already decrypted by autoDecrypt
124
- );
135
+ // Check if user is active
136
+ if (user.active !== undefined && !user.active) {
137
+ const response = formatter.unauthorized('User account is inactive');
138
+ return c.json(response, response._status);
139
+ }
125
140
 
126
- // For secret fields, we need to manually decrypt if autoDecrypt is off
127
- // But by default autoDecrypt is true, so user.password should be plain text here
128
- // Let's just compare directly since schema handles encryption/decryption
129
- const isValid = user.password === password;
141
+ // Verify password (compare with password field)
142
+ // Schema handles encryption/decryption for 'secret' field types
143
+ const isValid = user[passwordField] === password;
130
144
 
131
- if (!isValid) {
132
- const response = formatter.unauthorized('Invalid credentials');
133
- return c.json(response, response._status);
134
- }
145
+ if (!isValid) {
146
+ const response = formatter.unauthorized('Invalid credentials');
147
+ return c.json(response, response._status);
148
+ }
135
149
 
136
- // Update last login
137
- await usersResource.update(user.id, {
138
- lastLoginAt: new Date().toISOString()
139
- });
150
+ // Update last login if field exists
151
+ if (user.lastLoginAt !== undefined) {
152
+ await authResource.update(user.id, {
153
+ lastLoginAt: new Date().toISOString()
154
+ });
155
+ }
140
156
 
141
- // Generate JWT token
142
- let token = null;
143
- if (jwtSecret) {
144
- token = createToken(
145
- { userId: user.id, username: user.username, role: user.role },
146
- jwtSecret,
147
- jwtExpiresIn
148
- );
149
- }
157
+ // Generate JWT token
158
+ let token = null;
159
+ if (jwtSecret) {
160
+ token = createToken(
161
+ {
162
+ userId: user.id,
163
+ [usernameField]: user[usernameField],
164
+ role: user.role
165
+ },
166
+ jwtSecret,
167
+ jwtExpiresIn
168
+ );
169
+ }
150
170
 
151
- // Remove sensitive data from response
152
- const { password: _, ...userWithoutPassword } = user;
171
+ // Remove sensitive data from response
172
+ const { [passwordField]: _, ...userWithoutPassword } = user;
153
173
 
154
- const response = formatter.success({
155
- user: userWithoutPassword,
156
- token,
157
- expiresIn: jwtExpiresIn
158
- });
174
+ const response = formatter.success({
175
+ user: userWithoutPassword,
176
+ token,
177
+ expiresIn: jwtExpiresIn
178
+ });
159
179
 
160
- return c.json(response, response._status);
161
- }));
180
+ return c.json(response, response._status);
181
+ }));
182
+ }
162
183
 
163
184
  // POST /auth/token/refresh - Refresh JWT token
164
185
  if (jwtSecret) {
@@ -58,11 +58,12 @@ export function createResourceRoutes(resource, version, config = {}, Hono) {
58
58
  const {
59
59
  methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
60
60
  customMiddleware = [],
61
- enableValidation = true
61
+ enableValidation = true,
62
+ versionPrefix = '' // Empty string by default (calculated in server.js)
62
63
  } = config;
63
64
 
64
65
  const resourceName = resource.name;
65
- const basePath = `/${version}/${resourceName}`;
66
+ const basePath = versionPrefix ? `/${versionPrefix}/${resourceName}` : `/${resourceName}`;
66
67
 
67
68
  // Apply custom middleware
68
69
  customMiddleware.forEach(middleware => {