@rebasepro/server-core 0.2.3 → 0.2.4
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/dist/common/src/collections/default-collections.d.ts +12 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/util/permissions.d.ts +1 -0
- package/dist/{index-BZoAtuqi.js → index-Cr1D21av.js} +11 -3
- package/dist/index-Cr1D21av.js.map +1 -0
- package/dist/index.es.js +2166 -208
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +2155 -193
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/logs-routes.d.ts +37 -0
- package/dist/server-core/src/api/rest/api-generator.d.ts +6 -0
- package/dist/server-core/src/api/types.d.ts +6 -1
- package/dist/server-core/src/auth/adapter-middleware.d.ts +7 -3
- package/dist/server-core/src/auth/admin-routes.d.ts +3 -3
- package/dist/server-core/src/auth/api-keys/api-key-middleware.d.ts +39 -0
- package/dist/server-core/src/auth/api-keys/api-key-permission-guard.d.ts +32 -0
- package/dist/server-core/src/auth/api-keys/api-key-routes.d.ts +20 -0
- package/dist/server-core/src/auth/api-keys/api-key-store.d.ts +35 -0
- package/dist/server-core/src/auth/api-keys/api-key-types.d.ts +88 -0
- package/dist/server-core/src/auth/api-keys/index.d.ts +17 -0
- package/dist/server-core/src/auth/{auth-overrides.d.ts → auth-hooks.d.ts} +69 -11
- package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +3 -3
- package/dist/server-core/src/auth/index.d.ts +5 -3
- package/dist/server-core/src/auth/interfaces.d.ts +93 -3
- package/dist/server-core/src/auth/jwt.d.ts +3 -1
- package/dist/server-core/src/auth/mfa.d.ts +49 -0
- package/dist/server-core/src/auth/middleware.d.ts +7 -0
- package/dist/server-core/src/auth/rate-limiter.d.ts +19 -0
- package/dist/server-core/src/auth/routes.d.ts +3 -3
- package/dist/server-core/src/env.d.ts +6 -0
- package/dist/server-core/src/index.d.ts +1 -0
- package/dist/server-core/src/init.d.ts +3 -3
- package/dist/server-core/src/services/webhook-service.d.ts +29 -0
- package/dist/server-core/src/storage/image-transform.d.ts +48 -0
- package/dist/server-core/src/storage/index.d.ts +3 -0
- package/dist/server-core/src/storage/tus-handler.d.ts +51 -0
- package/dist/types/src/controllers/auth.d.ts +2 -24
- package/dist/types/src/controllers/client.d.ts +0 -3
- package/dist/types/src/controllers/collection_registry.d.ts +1 -1
- package/dist/types/src/controllers/data_driver.d.ts +18 -0
- package/dist/types/src/controllers/registry.d.ts +5 -4
- package/dist/types/src/rebase_context.d.ts +1 -1
- package/dist/types/src/types/auth_adapter.d.ts +2 -4
- package/dist/types/src/types/collections.d.ts +0 -4
- package/dist/types/src/types/component_ref.d.ts +1 -1
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +1 -0
- package/dist/types/src/types/export_import.d.ts +1 -1
- package/dist/types/src/types/formex.d.ts +2 -2
- package/dist/types/src/types/properties.d.ts +2 -2
- package/dist/types/src/types/translations.d.ts +28 -12
- package/dist/types/src/types/user_management_delegate.d.ts +6 -4
- package/dist/types/src/users/roles.d.ts +0 -8
- package/package.json +8 -6
- package/src/api/ast-schema-editor.ts +4 -4
- package/src/api/errors.ts +16 -7
- package/src/api/logs-routes.ts +129 -0
- package/src/api/rest/api-generator.ts +42 -2
- package/src/api/rest/query-parser.ts +37 -1
- package/src/api/types.ts +6 -1
- package/src/auth/adapter-middleware.ts +20 -4
- package/src/auth/admin-routes.ts +39 -14
- package/src/auth/api-keys/api-key-middleware.ts +126 -0
- package/src/auth/api-keys/api-key-permission-guard.ts +64 -0
- package/src/auth/api-keys/api-key-routes.ts +183 -0
- package/src/auth/api-keys/api-key-store.ts +317 -0
- package/src/auth/api-keys/api-key-types.ts +94 -0
- package/src/auth/api-keys/index.ts +37 -0
- package/src/auth/{auth-overrides.ts → auth-hooks.ts} +81 -14
- package/src/auth/builtin-auth-adapter.ts +31 -19
- package/src/auth/index.ts +7 -3
- package/src/auth/interfaces.ts +111 -3
- package/src/auth/jwt.ts +19 -5
- package/src/auth/mfa.ts +160 -0
- package/src/auth/middleware.ts +20 -1
- package/src/auth/rate-limiter.ts +92 -0
- package/src/auth/routes.ts +455 -24
- package/src/cron/cron-loader.ts +5 -10
- package/src/cron/cron-scheduler.ts +11 -12
- package/src/cron/cron-store.ts +8 -7
- package/src/env.ts +2 -0
- package/src/functions/function-loader.ts +6 -9
- package/src/index.ts +1 -2
- package/src/init.ts +37 -7
- package/src/serve-spa.ts +5 -4
- package/src/services/webhook-service.ts +155 -0
- package/src/storage/image-transform.ts +202 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/routes.ts +56 -3
- package/src/storage/tus-handler.ts +315 -0
- package/src/utils/dev-port.ts +14 -0
- package/src/utils/logging.ts +9 -7
- package/test/admin-routes.test.ts +74 -7
- package/test/api-generator.test.ts +0 -1
- package/test/api-key-permission-guard.test.ts +132 -0
- package/test/ast-schema-editor.test.ts +26 -0
- package/test/auth-routes.test.ts +1 -2
- package/test/backend-hooks-admin.test.ts +3 -4
- package/test/email-templates.test.ts +169 -0
- package/test/function-loader.test.ts +124 -0
- package/test/jwt.test.ts +4 -2
- package/test/mfa.test.ts +197 -0
- package/test/middleware.test.ts +10 -5
- package/test/webhook-service.test.ts +249 -0
- package/vite.config.ts +3 -2
- package/dist/index-BZoAtuqi.js.map +0 -1
- package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
- package/src/bootstrappers/index.ts +0 -1
- package/src/singleton.test.ts +0 -28
|
@@ -105,6 +105,12 @@ export interface RebaseTranslations {
|
|
|
105
105
|
navigation_drawer: string;
|
|
106
106
|
collapse: string;
|
|
107
107
|
expand: string;
|
|
108
|
+
/** Tooltip for the language switcher in the drawer footer */
|
|
109
|
+
change_language?: string;
|
|
110
|
+
/** Tooltip for the theme toggle in the drawer footer */
|
|
111
|
+
toggle_theme?: string;
|
|
112
|
+
/** Aria label for the user menu trigger in the drawer footer */
|
|
113
|
+
user_menu?: string;
|
|
108
114
|
error: string;
|
|
109
115
|
error_uploading_file: string;
|
|
110
116
|
error_deleting: string;
|
|
@@ -426,18 +432,28 @@ export interface RebaseTranslations {
|
|
|
426
432
|
deleted: string;
|
|
427
433
|
select_reference: string;
|
|
428
434
|
select_references: string;
|
|
429
|
-
account_settings
|
|
430
|
-
profile
|
|
431
|
-
sessions
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
435
|
+
account_settings?: string;
|
|
436
|
+
profile?: string;
|
|
437
|
+
sessions?: string;
|
|
438
|
+
security?: string;
|
|
439
|
+
change_password?: string;
|
|
440
|
+
current_password?: string;
|
|
441
|
+
new_password?: string;
|
|
442
|
+
confirm_password?: string;
|
|
443
|
+
password_changed?: string;
|
|
444
|
+
passwords_dont_match?: string;
|
|
445
|
+
password_too_short?: string;
|
|
446
|
+
password_change_not_available?: string;
|
|
447
|
+
changing_password?: string;
|
|
448
|
+
display_name?: string;
|
|
449
|
+
photo_url?: string;
|
|
450
|
+
save_profile?: string;
|
|
451
|
+
saving?: string;
|
|
452
|
+
no_active_sessions?: string;
|
|
453
|
+
revoking?: string;
|
|
454
|
+
revoke_all_sessions?: string;
|
|
455
|
+
unknown_device?: string;
|
|
456
|
+
current?: string;
|
|
441
457
|
role_id: string;
|
|
442
458
|
role_name: string;
|
|
443
459
|
add_reference: string;
|
|
@@ -104,15 +104,17 @@ export interface UserManagementDelegate<USER extends User = User> {
|
|
|
104
104
|
* If true, the UI will allow the user to create the default roles (admin, editor, viewer).
|
|
105
105
|
*/
|
|
106
106
|
allowDefaultRolesCreation?: boolean;
|
|
107
|
-
/**
|
|
108
|
-
* Should collection config permissions be included?
|
|
109
|
-
*/
|
|
110
|
-
includeCollectionConfigPermissions?: boolean;
|
|
111
107
|
/**
|
|
112
108
|
* Optionally define roles for a given user. This is useful when the roles
|
|
113
109
|
* are coming from a separate provider than the one issuing the tokens.
|
|
114
110
|
*/
|
|
115
111
|
defineRolesFor?: (user: USER) => Promise<Role[] | undefined> | Role[] | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* Whether any admin users exist. Used by the bootstrap banner to decide
|
|
114
|
+
* whether to prompt. Populated via a lightweight check (e.g. `limit=1`
|
|
115
|
+
* query) instead of loading all users.
|
|
116
|
+
*/
|
|
117
|
+
hasAdminUsers?: boolean;
|
|
116
118
|
/**
|
|
117
119
|
* Optional function to bootstrap an admin user.
|
|
118
120
|
* Often used when the database is empty.
|
|
@@ -11,12 +11,4 @@ export type Role = {
|
|
|
11
11
|
* If this flag is true, the user can perform any action
|
|
12
12
|
*/
|
|
13
13
|
isAdmin?: boolean;
|
|
14
|
-
/**
|
|
15
|
-
* Permissions related to editing the collections
|
|
16
|
-
*/
|
|
17
|
-
config?: {
|
|
18
|
-
createCollections?: boolean;
|
|
19
|
-
editCollections?: boolean | "own";
|
|
20
|
-
deleteCollections?: boolean | "own";
|
|
21
|
-
};
|
|
22
14
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-core",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.4",
|
|
5
5
|
"description": "Database-Agnostic Backend Core for Rebase",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -50,13 +50,14 @@
|
|
|
50
50
|
"hono": "^4.12.21",
|
|
51
51
|
"jsonwebtoken": "^9.0.3",
|
|
52
52
|
"nodemailer": "^6.10.1",
|
|
53
|
+
"sharp": "^0.34.5",
|
|
53
54
|
"ts-morph": "27.0.2",
|
|
54
55
|
"ws": "^8.20.1",
|
|
55
56
|
"zod": "^3.25.76",
|
|
56
|
-
"@rebasepro/
|
|
57
|
-
"@rebasepro/
|
|
58
|
-
"@rebasepro/
|
|
59
|
-
"@rebasepro/
|
|
57
|
+
"@rebasepro/client": "0.2.4",
|
|
58
|
+
"@rebasepro/types": "0.2.4",
|
|
59
|
+
"@rebasepro/utils": "0.2.4",
|
|
60
|
+
"@rebasepro/common": "0.2.4"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@types/jest": "^29.5.14",
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
"@types/react-dom": "^19.2.3",
|
|
68
69
|
"@types/ws": "^8.18.1",
|
|
69
70
|
"@vitejs/plugin-react": "^4.7.0",
|
|
71
|
+
"cross-env": "^7.0.3",
|
|
70
72
|
"jest": "^29.7.0",
|
|
71
73
|
"ts-jest": "29.4.1",
|
|
72
74
|
"typescript": "^5.9.3",
|
|
@@ -80,7 +82,7 @@
|
|
|
80
82
|
"watch": "vite build --watch",
|
|
81
83
|
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
|
|
82
84
|
"test:lint": "eslint \"src/**\" --quiet",
|
|
83
|
-
"test": "jest --passWithNoTests --forceExit",
|
|
85
|
+
"test": "cross-env NODE_OPTIONS=\"--experimental-vm-modules\" jest --passWithNoTests --forceExit",
|
|
84
86
|
"clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
|
|
85
87
|
}
|
|
86
88
|
}
|
|
@@ -89,7 +89,7 @@ export class AstSchemaEditor {
|
|
|
89
89
|
return "undefined";
|
|
90
90
|
}
|
|
91
91
|
if (typeof obj === "string") {
|
|
92
|
-
return
|
|
92
|
+
return JSON.stringify(obj);
|
|
93
93
|
}
|
|
94
94
|
if (typeof obj === "number" || typeof obj === "boolean") {
|
|
95
95
|
return String(obj);
|
|
@@ -127,7 +127,7 @@ export class AstSchemaEditor {
|
|
|
127
127
|
|
|
128
128
|
if (isCode || name === "target" || name === "callbacks" || name === "permissions" || name === "securityRules") {
|
|
129
129
|
// Preserve this property exactly as it was
|
|
130
|
-
const keyStr = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name :
|
|
130
|
+
const keyStr = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
131
131
|
preservedProps.push(`${keyStr}: ${init.getText()}`);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
@@ -139,7 +139,7 @@ export class AstSchemaEditor {
|
|
|
139
139
|
if (keys.length === 0 && preservedProps.length === 0) return "{}";
|
|
140
140
|
|
|
141
141
|
const props = keys.map(key => {
|
|
142
|
-
const keyStr = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key :
|
|
142
|
+
const keyStr = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
|
143
143
|
|
|
144
144
|
// If the value is an object, pass the old AST node to recurse
|
|
145
145
|
let childAstNode: ObjectLiteralExpression | undefined;
|
|
@@ -192,7 +192,7 @@ export class AstSchemaEditor {
|
|
|
192
192
|
}
|
|
193
193
|
} else {
|
|
194
194
|
propsObj.addPropertyAssignment({
|
|
195
|
-
name: /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(propertyKey) ? propertyKey :
|
|
195
|
+
name: /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(propertyKey) ? propertyKey : JSON.stringify(propertyKey),
|
|
196
196
|
initializer: newInitializer
|
|
197
197
|
});
|
|
198
198
|
}
|
package/src/api/errors.ts
CHANGED
|
@@ -115,15 +115,24 @@ export const errorHandler: ErrorHandler = (err, c) => {
|
|
|
115
115
|
logMessage = `Database schema mismatch (${issue} missing): ${error.message}. Did you forget to run migrations ('pnpm db:push' or 'pnpm db:migrate')?`;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
// Unexpected errors — log at error level
|
|
119
|
-
console.error(
|
|
120
|
-
`❌ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}`
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
// Suppress the huge stack trace for known DB errors (it's noisy and leaks SQL)
|
|
124
118
|
const causePg = (error.cause && typeof error.cause === "object") ? (error.cause as PgLikeError) : undefined;
|
|
125
119
|
const pgErrorCode = causePg?.code || error.code;
|
|
126
|
-
const
|
|
120
|
+
const isDbSchemaMismatch = pgErrorCode === "42703" || pgErrorCode === "42P01";
|
|
121
|
+
|
|
122
|
+
if (isDbSchemaMismatch) {
|
|
123
|
+
// Database schema mismatch is logged as a warning instead of a fatal error
|
|
124
|
+
console.warn(
|
|
125
|
+
`⚠️ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}`
|
|
126
|
+
);
|
|
127
|
+
} else {
|
|
128
|
+
// Unexpected errors — log at error level
|
|
129
|
+
console.error(
|
|
130
|
+
`❌ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Suppress the huge stack trace for known DB errors (it's noisy and leaks SQL)
|
|
135
|
+
const suppressStack = isDbSchemaMismatch || (statusCode < 500 && code === "BAD_REQUEST");
|
|
127
136
|
if (!suppressStack) {
|
|
128
137
|
console.error(error.stack || error);
|
|
129
138
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { MiddlewareHandler } from "hono";
|
|
3
|
+
import type { HonoEnv } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface LogEntry {
|
|
6
|
+
id: string;
|
|
7
|
+
timestamp: string;
|
|
8
|
+
level: "debug" | "info" | "warn" | "error";
|
|
9
|
+
source: "api" | "auth" | "storage" | "realtime" | "system";
|
|
10
|
+
message: string;
|
|
11
|
+
metadata?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class LogRingBuffer {
|
|
15
|
+
private buffer: LogEntry[] = [];
|
|
16
|
+
private maxSize: number;
|
|
17
|
+
private idCounter = 0;
|
|
18
|
+
|
|
19
|
+
constructor(maxSize = 10000) {
|
|
20
|
+
this.maxSize = maxSize;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
push(entry: Omit<LogEntry, "id">): void {
|
|
24
|
+
const id = `log_${++this.idCounter}`;
|
|
25
|
+
this.buffer.push({ ...entry, id });
|
|
26
|
+
if (this.buffer.length > this.maxSize) {
|
|
27
|
+
this.buffer.shift();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
query(options: {
|
|
32
|
+
level?: string;
|
|
33
|
+
source?: string;
|
|
34
|
+
search?: string;
|
|
35
|
+
limit?: number;
|
|
36
|
+
offset?: number;
|
|
37
|
+
since?: string;
|
|
38
|
+
}): { entries: LogEntry[]; total: number } {
|
|
39
|
+
let filtered = this.buffer;
|
|
40
|
+
|
|
41
|
+
if (options.level) {
|
|
42
|
+
filtered = filtered.filter(e => e.level === options.level);
|
|
43
|
+
}
|
|
44
|
+
if (options.source) {
|
|
45
|
+
filtered = filtered.filter(e => e.source === options.source);
|
|
46
|
+
}
|
|
47
|
+
if (options.search) {
|
|
48
|
+
const searchLower = options.search.toLowerCase();
|
|
49
|
+
filtered = filtered.filter(e => e.message.toLowerCase().includes(searchLower));
|
|
50
|
+
}
|
|
51
|
+
if (options.since) {
|
|
52
|
+
const sinceValue = options.since;
|
|
53
|
+
filtered = filtered.filter(e => e.timestamp >= sinceValue);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Newest first
|
|
57
|
+
const sorted = [...filtered].reverse();
|
|
58
|
+
const total = sorted.length;
|
|
59
|
+
const limit = options.limit || 100;
|
|
60
|
+
const offset = options.offset || 0;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
entries: sorted.slice(offset, offset + limit),
|
|
64
|
+
total
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getLatest(count = 50): LogEntry[] {
|
|
69
|
+
return this.buffer.slice(-count).reverse();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Global singleton
|
|
74
|
+
export const logBuffer = new LogRingBuffer();
|
|
75
|
+
|
|
76
|
+
/** Add a log entry */
|
|
77
|
+
export function addLog(
|
|
78
|
+
level: LogEntry["level"],
|
|
79
|
+
source: LogEntry["source"],
|
|
80
|
+
message: string,
|
|
81
|
+
metadata?: Record<string, unknown>
|
|
82
|
+
): void {
|
|
83
|
+
logBuffer.push({
|
|
84
|
+
timestamp: new Date().toISOString(),
|
|
85
|
+
level,
|
|
86
|
+
source,
|
|
87
|
+
message,
|
|
88
|
+
metadata
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Hono middleware to log API requests */
|
|
93
|
+
export function logMiddleware(): MiddlewareHandler<HonoEnv> {
|
|
94
|
+
return async (c, next) => {
|
|
95
|
+
const start = Date.now();
|
|
96
|
+
await next();
|
|
97
|
+
const duration = Date.now() - start;
|
|
98
|
+
addLog("info", "api", `${c.req.method} ${c.req.path} ${c.res.status} ${duration}ms`, {
|
|
99
|
+
method: c.req.method,
|
|
100
|
+
path: c.req.path,
|
|
101
|
+
status: c.res.status,
|
|
102
|
+
duration
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const app = new Hono<HonoEnv>();
|
|
108
|
+
|
|
109
|
+
// GET /api/logs — Query logs
|
|
110
|
+
app.get("/", (c) => {
|
|
111
|
+
const query = c.req.query();
|
|
112
|
+
const result = logBuffer.query({
|
|
113
|
+
level: query.level,
|
|
114
|
+
source: query.source,
|
|
115
|
+
search: query.search,
|
|
116
|
+
limit: query.limit ? parseInt(query.limit) : undefined,
|
|
117
|
+
offset: query.offset ? parseInt(query.offset) : undefined,
|
|
118
|
+
since: query.since
|
|
119
|
+
});
|
|
120
|
+
return c.json(result);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// GET /api/logs/latest — Get latest logs (for real-time)
|
|
124
|
+
app.get("/latest", (c) => {
|
|
125
|
+
const count = parseInt(c.req.query("count") || "50");
|
|
126
|
+
return c.json({ entries: logBuffer.getLatest(count) });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export default app;
|
|
@@ -3,6 +3,8 @@ import { DataDriver, Entity, EntityCollection, FetchCollectionProps, DataHooks,
|
|
|
3
3
|
import { QueryOptions, HonoEnv } from "../types";
|
|
4
4
|
import { ApiError } from "../errors";
|
|
5
5
|
import { parseQueryOptions } from "./query-parser";
|
|
6
|
+
import { httpMethodToOperation, isOperationAllowed } from "../../auth/api-keys/api-key-permission-guard";
|
|
7
|
+
import type { ApiKeyMasked } from "../../auth/api-keys/api-key-types";
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -47,6 +49,27 @@ export class RestApiGenerator {
|
|
|
47
49
|
return this.router;
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Check API key permissions for a collection operation.
|
|
54
|
+
* Throws 403 if the key doesn't have the required permission.
|
|
55
|
+
* No-ops if the request is not authenticated via an API key.
|
|
56
|
+
*/
|
|
57
|
+
private enforceApiKeyPermission(
|
|
58
|
+
c: { get: (key: string) => unknown; req: { method: string } },
|
|
59
|
+
collectionSlug: string,
|
|
60
|
+
): void {
|
|
61
|
+
const apiKey = c.get("apiKey") as ApiKeyMasked | undefined;
|
|
62
|
+
if (!apiKey) return; // Not an API key request — skip
|
|
63
|
+
|
|
64
|
+
const operation = httpMethodToOperation(c.req.method);
|
|
65
|
+
if (!isOperationAllowed(apiKey.permissions, collectionSlug, operation)) {
|
|
66
|
+
throw ApiError.forbidden(
|
|
67
|
+
`API key does not have "${operation}" permission for collection "${collectionSlug}"`,
|
|
68
|
+
"API_KEY_FORBIDDEN",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
/**
|
|
51
74
|
* Get the typed RestFetchService from a driver if it exposes one (for include support).
|
|
52
75
|
*/
|
|
@@ -63,6 +86,7 @@ export class RestApiGenerator {
|
|
|
63
86
|
|
|
64
87
|
// GET /collection/count - Count entities (with optional filters)
|
|
65
88
|
this.router.get(`${basePath}/count`, async (c) => {
|
|
89
|
+
this.enforceApiKeyPermission(c, collection.slug);
|
|
66
90
|
const queryDict = c.req.query();
|
|
67
91
|
const queryOptions = parseQueryOptions(queryDict);
|
|
68
92
|
const searchString = queryDict.searchString as string | undefined;
|
|
@@ -74,6 +98,7 @@ export class RestApiGenerator {
|
|
|
74
98
|
|
|
75
99
|
// GET /collection - List entities
|
|
76
100
|
this.router.get(basePath, async (c) => {
|
|
101
|
+
this.enforceApiKeyPermission(c, collection.slug);
|
|
77
102
|
const queryDict = c.req.query();
|
|
78
103
|
const queryOptions = parseQueryOptions(queryDict);
|
|
79
104
|
const searchString = queryDict.searchString as string | undefined;
|
|
@@ -93,7 +118,8 @@ export class RestApiGenerator {
|
|
|
93
118
|
offset: queryOptions.offset,
|
|
94
119
|
orderBy: queryOptions.orderBy?.[0]?.field,
|
|
95
120
|
order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
|
|
96
|
-
searchString
|
|
121
|
+
searchString,
|
|
122
|
+
vectorSearch: queryOptions.vectorSearch
|
|
97
123
|
},
|
|
98
124
|
queryOptions.include
|
|
99
125
|
);
|
|
@@ -133,6 +159,7 @@ export class RestApiGenerator {
|
|
|
133
159
|
|
|
134
160
|
// GET /collection/:id - Get single entity
|
|
135
161
|
this.router.get(`${basePath}/:id`, async (c) => {
|
|
162
|
+
this.enforceApiKeyPermission(c, collection.slug);
|
|
136
163
|
const id = c.req.param("id");
|
|
137
164
|
const queryDict = c.req.query();
|
|
138
165
|
const queryOptions = parseQueryOptions(queryDict);
|
|
@@ -179,6 +206,7 @@ export class RestApiGenerator {
|
|
|
179
206
|
// POST /collection - Create entity
|
|
180
207
|
this.router.post(basePath, async (c) => {
|
|
181
208
|
try {
|
|
209
|
+
this.enforceApiKeyPermission(c, collection.slug);
|
|
182
210
|
const driver = c.get("driver") || this.driver;
|
|
183
211
|
const path = collection.slug;
|
|
184
212
|
const hookCtx = this.buildHookContext(c, "POST");
|
|
@@ -215,6 +243,7 @@ export class RestApiGenerator {
|
|
|
215
243
|
// PUT /collection/:id - Update entity
|
|
216
244
|
this.router.put(`${basePath}/:id`, async (c) => {
|
|
217
245
|
try {
|
|
246
|
+
this.enforceApiKeyPermission(c, collection.slug);
|
|
218
247
|
const id = c.req.param("id");
|
|
219
248
|
const driver = c.get("driver") || this.driver;
|
|
220
249
|
const hookCtx = this.buildHookContext(c, "PUT");
|
|
@@ -261,6 +290,7 @@ export class RestApiGenerator {
|
|
|
261
290
|
|
|
262
291
|
// DELETE /collection/:id - Delete entity
|
|
263
292
|
this.router.delete(`${basePath}/:id`, async (c) => {
|
|
293
|
+
this.enforceApiKeyPermission(c, collection.slug);
|
|
264
294
|
const id = c.req.param("id");
|
|
265
295
|
const driver = c.get("driver") || this.driver;
|
|
266
296
|
const hookCtx = this.buildHookContext(c, "DELETE");
|
|
@@ -354,6 +384,8 @@ entityId };
|
|
|
354
384
|
|
|
355
385
|
const driver = c.get("driver") || this.driver;
|
|
356
386
|
|
|
387
|
+
this.enforceApiKeyPermission(c, c.req.param("parent"));
|
|
388
|
+
|
|
357
389
|
if (parsed.entityId === "count") {
|
|
358
390
|
// GET /parent/:parentId/child/count — count child entities
|
|
359
391
|
const queryDict = c.req.query();
|
|
@@ -407,6 +439,8 @@ entityId };
|
|
|
407
439
|
if (!parsed || parsed.entityId) return next();
|
|
408
440
|
|
|
409
441
|
const driver = c.get("driver") || this.driver;
|
|
442
|
+
|
|
443
|
+
this.enforceApiKeyPermission(c, c.req.param("parent"));
|
|
410
444
|
const body = await c.req.json().catch(() => ({}));
|
|
411
445
|
|
|
412
446
|
const entity = await driver.saveEntity({
|
|
@@ -427,6 +461,9 @@ entityId };
|
|
|
427
461
|
if (!parsed || !parsed.entityId) return next();
|
|
428
462
|
|
|
429
463
|
const driver = c.get("driver") || this.driver;
|
|
464
|
+
|
|
465
|
+
this.enforceApiKeyPermission(c, c.req.param("parent"));
|
|
466
|
+
|
|
430
467
|
const body = await c.req.json().catch(() => ({}));
|
|
431
468
|
|
|
432
469
|
const entity = await driver.saveEntity({
|
|
@@ -449,6 +486,8 @@ entityId };
|
|
|
449
486
|
|
|
450
487
|
const driver = c.get("driver") || this.driver;
|
|
451
488
|
|
|
489
|
+
this.enforceApiKeyPermission(c, c.req.param("parent"));
|
|
490
|
+
|
|
452
491
|
const existingEntity = await driver.fetchEntity({
|
|
453
492
|
path: parsed.collectionPath,
|
|
454
493
|
entityId: parsed.entityId
|
|
@@ -520,7 +559,8 @@ entityId };
|
|
|
520
559
|
orderBy: queryOptions.orderBy?.[0]?.field,
|
|
521
560
|
order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
|
|
522
561
|
startAfter: queryOptions.offset ? String(queryOptions.offset) : undefined,
|
|
523
|
-
searchString
|
|
562
|
+
searchString,
|
|
563
|
+
vectorSearch: queryOptions.vectorSearch
|
|
524
564
|
});
|
|
525
565
|
|
|
526
566
|
return entities.map(entity => this.flattenEntity(entity));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { VectorSearchParams } from "@rebasepro/types";
|
|
1
2
|
import { QueryOptions } from "../types";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -39,7 +40,7 @@ export function parseQueryOptions(query: Record<string, unknown>): QueryOptions
|
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
// PostgREST-style filtering: ?field=op.value
|
|
42
|
-
const reservedQueryKeys = ["limit", "offset", "page", "orderBy", "include", "fields", "searchString"];
|
|
43
|
+
const reservedQueryKeys = ["limit", "offset", "page", "orderBy", "include", "fields", "searchString", "vector_search", "vector", "vector_distance", "vector_threshold"];
|
|
43
44
|
for (const [key, rawValue] of Object.entries(query)) {
|
|
44
45
|
if (reservedQueryKeys.includes(key)) continue;
|
|
45
46
|
|
|
@@ -133,5 +134,40 @@ export function parseQueryOptions(query: Record<string, unknown>): QueryOptions
|
|
|
133
134
|
options.fields = fieldsStr.split(",").map(s => s.trim()).filter(Boolean);
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
// Vector similarity search
|
|
138
|
+
if (query.vector_search && query.vector) {
|
|
139
|
+
const vectorStr = String(query.vector);
|
|
140
|
+
let queryVector: number[];
|
|
141
|
+
try {
|
|
142
|
+
queryVector = JSON.parse(vectorStr) as number[];
|
|
143
|
+
if (!Array.isArray(queryVector) || !queryVector.every(v => typeof v === "number")) {
|
|
144
|
+
throw new Error("Expected array of numbers");
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
throw new Error("Invalid vector format. Expected JSON array of numbers, e.g. [0.1,0.2,0.3]");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const distanceParam = query.vector_distance ? String(query.vector_distance) : "cosine";
|
|
151
|
+
if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") {
|
|
152
|
+
throw new Error(`Invalid vector_distance: ${distanceParam}. Expected: cosine, l2, or inner_product`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const vectorSearch: VectorSearchParams = {
|
|
156
|
+
property: String(query.vector_search),
|
|
157
|
+
vector: queryVector,
|
|
158
|
+
distance: distanceParam,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (query.vector_threshold) {
|
|
162
|
+
const threshold = parseFloat(String(query.vector_threshold));
|
|
163
|
+
if (isNaN(threshold)) {
|
|
164
|
+
throw new Error("Invalid vector_threshold. Expected a number.");
|
|
165
|
+
}
|
|
166
|
+
vectorSearch.threshold = threshold;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
options.vectorSearch = vectorSearch;
|
|
170
|
+
}
|
|
171
|
+
|
|
136
172
|
return options;
|
|
137
173
|
}
|
package/src/api/types.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { EntityCollection } from "@rebasepro/types";
|
|
1
|
+
import { EntityCollection, VectorSearchParams } from "@rebasepro/types";
|
|
2
2
|
import { AuthResult } from "../auth/middleware";
|
|
3
3
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
import { DataDriver } from "@rebasepro/types";
|
|
5
|
+
import type { ApiKeyMasked } from "../auth/api-keys/api-key-types";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Hono Environment Variables
|
|
@@ -11,6 +12,8 @@ export type HonoEnv = {
|
|
|
11
12
|
Variables: {
|
|
12
13
|
user?: AuthResult | { userId?: string, roles?: string[] };
|
|
13
14
|
driver?: DataDriver;
|
|
15
|
+
/** Set when the request is authenticated via a Service API Key. */
|
|
16
|
+
apiKey?: ApiKeyMasked;
|
|
14
17
|
}
|
|
15
18
|
};
|
|
16
19
|
|
|
@@ -78,6 +81,8 @@ export interface QueryOptions {
|
|
|
78
81
|
include?: string[];
|
|
79
82
|
/** Columns to return in the response (field-level selection) */
|
|
80
83
|
fields?: string[];
|
|
84
|
+
/** Vector similarity search configuration */
|
|
85
|
+
vectorSearch?: VectorSearchParams;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
/**
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* an `AuthAdapter` to `initializeRebaseBackend()`.
|
|
7
7
|
*
|
|
8
8
|
* The middleware:
|
|
9
|
-
* 1.
|
|
10
|
-
* 2.
|
|
11
|
-
* 3.
|
|
9
|
+
* 1. Checks for API key tokens (`rk_` prefix) first — these are Rebase-level
|
|
10
|
+
* 2. Falls back to `adapter.verifyRequest(request)` to resolve the user
|
|
11
|
+
* 3. Scopes the DataDriver via `withAuth()` for RLS
|
|
12
|
+
* 4. Enforces auth (401) when `requireAuth` is true and no user is found
|
|
12
13
|
*
|
|
13
14
|
* The behavior is identical to `createAuthMiddleware()` — only the
|
|
14
15
|
* token verification strategy is pluggable.
|
|
@@ -17,7 +18,9 @@
|
|
|
17
18
|
import type { MiddlewareHandler } from "hono";
|
|
18
19
|
import type { DataDriver, AuthAdapter } from "@rebasepro/types";
|
|
19
20
|
import type { HonoEnv } from "../api/types";
|
|
21
|
+
import type { ApiKeyStore } from "./api-keys/api-key-store";
|
|
20
22
|
import { scopeDataDriver } from "./rls-scope";
|
|
23
|
+
import { validateApiKey } from "./api-keys/api-key-middleware";
|
|
21
24
|
|
|
22
25
|
export interface AdapterAuthMiddlewareOptions {
|
|
23
26
|
/** The auth adapter to delegate verification to. */
|
|
@@ -29,15 +32,28 @@ export interface AdapterAuthMiddlewareOptions {
|
|
|
29
32
|
* Defaults to `true` (secure by default).
|
|
30
33
|
*/
|
|
31
34
|
requireAuth?: boolean;
|
|
35
|
+
/** Optional API key store — when provided, `rk_` bearer tokens are accepted. */
|
|
36
|
+
apiKeyStore?: ApiKeyStore;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
/**
|
|
35
40
|
* Create a Hono middleware that uses an `AuthAdapter` for request verification.
|
|
36
41
|
*/
|
|
37
42
|
export function createAdapterAuthMiddleware(options: AdapterAuthMiddlewareOptions): MiddlewareHandler<HonoEnv> {
|
|
38
|
-
const { adapter, driver, requireAuth: enforceAuth = true } = options;
|
|
43
|
+
const { adapter, driver, requireAuth: enforceAuth = true, apiKeyStore } = options;
|
|
39
44
|
|
|
40
45
|
return async (c, next) => {
|
|
46
|
+
// ── API Key check (Rebase-level, independent of auth adapter) ────
|
|
47
|
+
if (apiKeyStore) {
|
|
48
|
+
const authHeader = c.req.header("authorization") || "";
|
|
49
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
50
|
+
if (token.startsWith("rk_")) {
|
|
51
|
+
const result = await validateApiKey(c, token, { store: apiKeyStore, driver });
|
|
52
|
+
if (result === true) return next();
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
let authenticatedUser = null;
|
|
42
58
|
|
|
43
59
|
try {
|