@rebasepro/server-core 0.0.1-canary.4d4fb3e → 0.0.1-canary.ca2cb6e
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/CollectionRegistry.d.ts +8 -0
- package/dist/common/src/util/entities.d.ts +22 -0
- package/dist/common/src/util/relations.d.ts +14 -4
- package/dist/common/src/util/resolutions.d.ts +1 -1
- package/dist/index-DXVBFp5V.js +37 -0
- package/dist/index-DXVBFp5V.js.map +1 -0
- package/dist/index.es.js +49931 -55
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +28475 -34569
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/openapi-generator.d.ts +15 -1
- package/dist/server-core/src/api/types.d.ts +1 -1
- package/dist/server-core/src/auth/admin-routes.d.ts +10 -1
- package/dist/server-core/src/auth/apple-oauth.d.ts +30 -0
- package/dist/server-core/src/auth/bitbucket-oauth.d.ts +11 -0
- package/dist/server-core/src/auth/discord-oauth.d.ts +14 -0
- package/dist/server-core/src/auth/facebook-oauth.d.ts +14 -0
- package/dist/server-core/src/auth/github-oauth.d.ts +15 -0
- package/dist/server-core/src/auth/gitlab-oauth.d.ts +13 -0
- package/dist/server-core/src/auth/google-oauth.d.ts +5 -11
- package/dist/server-core/src/auth/index.d.ts +13 -2
- package/dist/server-core/src/auth/interfaces.d.ts +45 -6
- package/dist/server-core/src/auth/jwt.d.ts +1 -0
- package/dist/server-core/src/auth/linkedin-oauth.d.ts +18 -0
- package/dist/server-core/src/auth/microsoft-oauth.d.ts +16 -0
- package/dist/server-core/src/auth/middleware.d.ts +36 -11
- package/dist/server-core/src/auth/rate-limiter.d.ts +2 -2
- package/dist/server-core/src/auth/routes.d.ts +13 -3
- package/dist/server-core/src/auth/slack-oauth.d.ts +12 -0
- package/dist/server-core/src/auth/spotify-oauth.d.ts +12 -0
- package/dist/server-core/src/auth/twitter-oauth.d.ts +18 -0
- package/dist/server-core/src/cron/cron-loader.d.ts +17 -0
- package/dist/server-core/src/cron/cron-routes.d.ts +14 -0
- package/dist/server-core/src/cron/cron-scheduler.d.ts +61 -0
- package/dist/server-core/src/cron/cron-store.d.ts +32 -0
- package/dist/server-core/src/cron/index.d.ts +6 -0
- package/dist/server-core/src/email/index.d.ts +2 -2
- package/dist/server-core/src/email/templates.d.ts +9 -0
- package/dist/server-core/src/email/types.d.ts +20 -23
- package/dist/server-core/src/history/history-routes.d.ts +2 -2
- package/dist/server-core/src/index.d.ts +5 -0
- package/dist/server-core/src/init.d.ts +112 -2
- package/dist/server-core/src/singleton.d.ts +35 -0
- package/dist/server-core/src/storage/LocalStorageController.d.ts +9 -9
- package/dist/server-core/src/storage/S3StorageController.d.ts +8 -8
- package/dist/server-core/src/storage/index.d.ts +18 -11
- package/dist/server-core/src/storage/routes.d.ts +3 -3
- package/dist/server-core/src/storage/types.d.ts +28 -16
- package/dist/server-core/src/utils/dev-port.d.ts +35 -0
- package/dist/server-core/src/utils/logger.d.ts +31 -0
- package/dist/server-core/src/utils/request-logger.d.ts +19 -0
- package/dist/types/src/controllers/auth.d.ts +2 -0
- package/dist/types/src/controllers/client.d.ts +119 -7
- package/dist/types/src/controllers/collection_registry.d.ts +4 -3
- package/dist/types/src/controllers/customization_controller.d.ts +7 -1
- package/dist/types/src/controllers/data.d.ts +34 -7
- package/dist/types/src/controllers/data_driver.d.ts +20 -28
- package/dist/types/src/controllers/database_admin.d.ts +2 -2
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +1 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
- package/dist/types/src/controllers/navigation.d.ts +5 -5
- package/dist/types/src/controllers/registry.d.ts +6 -3
- package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
- package/dist/types/src/controllers/storage.d.ts +24 -26
- package/dist/types/src/rebase_context.d.ts +8 -4
- package/dist/types/src/types/backend.d.ts +4 -1
- package/dist/types/src/types/builders.d.ts +5 -4
- package/dist/types/src/types/chips.d.ts +1 -1
- package/dist/types/src/types/collections.d.ts +169 -125
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +1 -1
- package/dist/types/src/types/entity_actions.d.ts +8 -8
- package/dist/types/src/types/entity_callbacks.d.ts +15 -15
- package/dist/types/src/types/entity_link_builder.d.ts +1 -1
- package/dist/types/src/types/entity_overrides.d.ts +2 -1
- package/dist/types/src/types/entity_views.d.ts +8 -8
- package/dist/types/src/types/export_import.d.ts +3 -3
- package/dist/types/src/types/index.d.ts +1 -0
- package/dist/types/src/types/plugins.d.ts +72 -18
- package/dist/types/src/types/properties.d.ts +118 -33
- package/dist/types/src/types/relations.d.ts +1 -1
- package/dist/types/src/types/slots.d.ts +30 -6
- package/dist/types/src/types/translations.d.ts +44 -0
- package/dist/types/src/types/user_management_delegate.d.ts +1 -0
- package/package.json +84 -84
- package/scratch.ts +6 -5
- package/src/api/ast-schema-editor.ts +11 -11
- package/src/api/collections_for_test/callbacks_test_collection.ts +5 -2
- package/src/api/errors.ts +38 -14
- package/src/api/graphql/graphql-schema-generator.ts +5 -3
- package/src/api/openapi-generator.ts +613 -58
- package/src/api/rest/api-generator.ts +17 -11
- package/src/api/rest/query-parser.ts +1 -1
- package/src/api/schema-editor-routes.ts +2 -0
- package/src/api/server.ts +8 -5
- package/src/api/types.ts +1 -1
- package/src/auth/admin-routes.ts +58 -17
- package/src/auth/apple-oauth.ts +130 -0
- package/src/auth/bitbucket-oauth.ts +82 -0
- package/src/auth/discord-oauth.ts +83 -0
- package/src/auth/facebook-oauth.ts +72 -0
- package/src/auth/github-oauth.ts +110 -0
- package/src/auth/gitlab-oauth.ts +70 -0
- package/src/auth/google-oauth.ts +34 -46
- package/src/auth/index.ts +15 -2
- package/src/auth/interfaces.ts +54 -7
- package/src/auth/jwt.ts +22 -5
- package/src/auth/linkedin-oauth.ts +81 -0
- package/src/auth/microsoft-oauth.ts +88 -0
- package/src/auth/middleware.ts +194 -45
- package/src/auth/password.ts +3 -1
- package/src/auth/rate-limiter.ts +4 -4
- package/src/auth/routes.ts +197 -139
- package/src/auth/slack-oauth.ts +71 -0
- package/src/auth/spotify-oauth.ts +67 -0
- package/src/auth/twitter-oauth.ts +120 -0
- package/src/collections/loader.ts +6 -6
- package/src/cron/cron-loader.ts +89 -0
- package/src/cron/cron-routes.test.ts +265 -0
- package/src/cron/cron-routes.ts +85 -0
- package/src/cron/cron-scheduler.test.ts +421 -0
- package/src/cron/cron-scheduler.ts +413 -0
- package/src/cron/cron-store.ts +163 -0
- package/src/cron/index.ts +6 -0
- package/src/db/interfaces.ts +2 -2
- package/src/email/index.ts +3 -2
- package/src/email/smtp-email-service.ts +5 -2
- package/src/email/templates.ts +93 -6
- package/src/email/types.ts +17 -24
- package/src/functions/function-loader.ts +34 -6
- package/src/functions/function-routes.ts +2 -2
- package/src/history/history-routes.ts +4 -3
- package/src/index.ts +10 -0
- package/src/init.ts +475 -57
- package/src/services/driver-registry.ts +1 -1
- package/src/singleton.test.ts +28 -0
- package/src/singleton.ts +70 -0
- package/src/storage/LocalStorageController.ts +50 -53
- package/src/storage/S3StorageController.ts +49 -46
- package/src/storage/index.ts +27 -16
- package/src/storage/routes.ts +76 -59
- package/src/storage/storage-registry.ts +5 -5
- package/src/storage/types.ts +44 -32
- package/src/types/index.ts +4 -4
- package/src/utils/dev-port.ts +176 -0
- package/src/utils/logger.ts +143 -0
- package/src/utils/logging.ts +5 -2
- package/src/utils/request-logger.ts +66 -0
- package/test/admin-routes.test.ts +114 -65
- package/test/api-generator.test.ts +73 -30
- package/test/ast-schema-editor.test.ts +5 -3
- package/test/auth-middleware-hono.test.ts +268 -33
- package/test/auth-routes.test.ts +325 -146
- package/test/driver-registry.test.ts +4 -2
- package/test/error-propagation.test.ts +226 -0
- package/test/errors.test.ts +12 -7
- package/test/jwt-security.test.ts +18 -9
- package/test/jwt.test.ts +26 -13
- package/test/middleware.test.ts +13 -8
- package/test/query-parser.test.ts +13 -8
- package/test/rate-limiter.test.ts +7 -7
- package/test/safe-compare.test.ts +66 -0
- package/test/singleton.test.ts +59 -0
- package/test/storage-local.test.ts +36 -43
- package/test/storage-registry.test.ts +38 -36
- package/test/storage-routes.test.ts +9 -5
- package/test/storage-s3.test.ts +51 -48
- package/test.ts +6 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +7 -5
- package/dist/index-BeMqpmfQ.js +0 -239
- package/dist/index-BeMqpmfQ.js.map +0 -1
- package/dist/index-bl4J3lNb.js +0 -55823
- package/dist/index-bl4J3lNb.js.map +0 -1
- package/vite.config.ts.timestamp-1775065397568-8a853255edf6e.mjs +0 -46
|
@@ -60,6 +60,7 @@ export class RestApiGenerator {
|
|
|
60
60
|
this.router.get(basePath, async (c) => {
|
|
61
61
|
const queryDict = c.req.query();
|
|
62
62
|
const queryOptions = parseQueryOptions(queryDict);
|
|
63
|
+
const searchString = queryDict.searchString as string | undefined;
|
|
63
64
|
|
|
64
65
|
const driver = c.get("driver") || this.driver;
|
|
65
66
|
const fetchService = this.getFetchService(driver);
|
|
@@ -72,14 +73,15 @@ export class RestApiGenerator {
|
|
|
72
73
|
{
|
|
73
74
|
filter: queryOptions.where as FetchCollectionProps["filter"],
|
|
74
75
|
limit: queryOptions.limit,
|
|
76
|
+
offset: queryOptions.offset,
|
|
75
77
|
orderBy: queryOptions.orderBy?.[0]?.field,
|
|
76
78
|
order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
|
|
77
|
-
searchString
|
|
79
|
+
searchString
|
|
78
80
|
},
|
|
79
81
|
queryOptions.include
|
|
80
82
|
);
|
|
81
83
|
|
|
82
|
-
const total = await this.countRawEntities(driver, resolvedCollection, queryOptions);
|
|
84
|
+
const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
|
|
83
85
|
|
|
84
86
|
return c.json({
|
|
85
87
|
data: entities,
|
|
@@ -92,9 +94,10 @@ export class RestApiGenerator {
|
|
|
92
94
|
});
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
// Fallback
|
|
96
|
-
const entities = await this.fetchRawCollection(driver, resolvedCollection, queryOptions);
|
|
97
|
-
|
|
97
|
+
// Fallback path
|
|
98
|
+
const entities = await this.fetchRawCollection(driver, resolvedCollection, queryOptions, searchString);
|
|
99
|
+
|
|
100
|
+
const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
|
|
98
101
|
|
|
99
102
|
return c.json({
|
|
100
103
|
data: entities,
|
|
@@ -146,7 +149,7 @@ export class RestApiGenerator {
|
|
|
146
149
|
try {
|
|
147
150
|
const driver = c.get("driver") || this.driver;
|
|
148
151
|
const path = collection.slug;
|
|
149
|
-
|
|
152
|
+
|
|
150
153
|
const body = await c.req.json().catch(() => ({}));
|
|
151
154
|
|
|
152
155
|
const entity = await driver.saveEntity({
|
|
@@ -265,7 +268,8 @@ export class RestApiGenerator {
|
|
|
265
268
|
return { collectionPath: segments.join("/") };
|
|
266
269
|
} else {
|
|
267
270
|
const entityId = segments.pop()!;
|
|
268
|
-
return { collectionPath: segments.join("/"),
|
|
271
|
+
return { collectionPath: segments.join("/"),
|
|
272
|
+
entityId };
|
|
269
273
|
}
|
|
270
274
|
};
|
|
271
275
|
|
|
@@ -426,7 +430,7 @@ export class RestApiGenerator {
|
|
|
426
430
|
/**
|
|
427
431
|
* Fetch raw collection data without Entity wrapper (fallback for non-Postgres)
|
|
428
432
|
*/
|
|
429
|
-
private async fetchRawCollection(driver: DataDriver, collection: EntityCollection, queryOptions: QueryOptions) {
|
|
433
|
+
private async fetchRawCollection(driver: DataDriver, collection: EntityCollection, queryOptions: QueryOptions, searchString?: string) {
|
|
430
434
|
const entities = await driver.fetchCollection({
|
|
431
435
|
path: collection.slug,
|
|
432
436
|
collection,
|
|
@@ -434,7 +438,8 @@ export class RestApiGenerator {
|
|
|
434
438
|
limit: queryOptions.limit,
|
|
435
439
|
orderBy: queryOptions.orderBy?.[0]?.field,
|
|
436
440
|
order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
|
|
437
|
-
startAfter: queryOptions.offset ? String(queryOptions.offset) : undefined
|
|
441
|
+
startAfter: queryOptions.offset ? String(queryOptions.offset) : undefined,
|
|
442
|
+
searchString
|
|
438
443
|
});
|
|
439
444
|
|
|
440
445
|
return entities.map(entity => this.flattenEntity(entity));
|
|
@@ -443,11 +448,12 @@ export class RestApiGenerator {
|
|
|
443
448
|
/**
|
|
444
449
|
* Count raw entities for a collection
|
|
445
450
|
*/
|
|
446
|
-
private async countRawEntities(driver: DataDriver, collection: EntityCollection, queryOptions: QueryOptions): Promise<number> {
|
|
451
|
+
private async countRawEntities(driver: DataDriver, collection: EntityCollection, queryOptions: QueryOptions, searchString?: string): Promise<number> {
|
|
447
452
|
return driver.countEntities ? await driver.countEntities({
|
|
448
453
|
path: collection.slug,
|
|
449
454
|
collection,
|
|
450
|
-
filter: queryOptions.where as FetchCollectionProps["filter"]
|
|
455
|
+
filter: queryOptions.where as FetchCollectionProps["filter"],
|
|
456
|
+
searchString
|
|
451
457
|
}) : 0;
|
|
452
458
|
}
|
|
453
459
|
|
|
@@ -57,7 +57,7 @@ export function parseQueryOptions(query: Record<string, unknown>): QueryOptions
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// PostgREST style filtering
|
|
60
|
-
const reservedQueryKeys = ["limit", "offset", "page", "orderBy", "where", "include", "fields"];
|
|
60
|
+
const reservedQueryKeys = ["limit", "offset", "page", "orderBy", "where", "include", "fields", "searchString"];
|
|
61
61
|
for (const [key, rawValue] of Object.entries(query)) {
|
|
62
62
|
if (reservedQueryKeys.includes(key)) continue;
|
|
63
63
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { AstSchemaEditor } from "./ast-schema-editor";
|
|
3
|
+
import { errorHandler } from "./errors";
|
|
3
4
|
import { HonoEnv } from "./types";
|
|
4
5
|
|
|
5
6
|
export function createSchemaEditorRoutes(collectionsDir: string): Hono<HonoEnv> {
|
|
6
7
|
const router = new Hono<HonoEnv>();
|
|
8
|
+
router.onError(errorHandler);
|
|
7
9
|
const editor = new AstSchemaEditor(collectionsDir);
|
|
8
10
|
|
|
9
11
|
router.post("/property/save", async (c) => {
|
package/src/api/server.ts
CHANGED
|
@@ -39,7 +39,7 @@ export class RebaseApiServer {
|
|
|
39
39
|
|
|
40
40
|
this.app = new Hono<HonoEnv>();
|
|
41
41
|
this.router = new Hono<HonoEnv>();
|
|
42
|
-
|
|
42
|
+
|
|
43
43
|
this.setupMiddleware();
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -57,7 +57,7 @@ export class RebaseApiServer {
|
|
|
57
57
|
server.setupRoutes();
|
|
58
58
|
// Since we mount routes directly to router, we can let consumer attach it
|
|
59
59
|
server.app.route("/", server.router);
|
|
60
|
-
|
|
60
|
+
|
|
61
61
|
// Hono global error handler on the root app
|
|
62
62
|
server.app.onError(errorHandler);
|
|
63
63
|
server.router.onError(errorHandler);
|
|
@@ -119,7 +119,7 @@ export class RebaseApiServer {
|
|
|
119
119
|
properties: Object.keys(col.properties),
|
|
120
120
|
relations: (col as EntityCollection & { relations?: Relation[] }).relations?.map((r: Relation) => ({
|
|
121
121
|
relationName: r.relationName,
|
|
122
|
-
target: typeof r.target ===
|
|
122
|
+
target: typeof r.target === "function" ? r.target().slug : r.target,
|
|
123
123
|
cardinality: r.cardinality,
|
|
124
124
|
direction: r.direction
|
|
125
125
|
})) || []
|
|
@@ -187,7 +187,10 @@ export class RebaseApiServer {
|
|
|
187
187
|
|
|
188
188
|
// OpenAPI endpoint
|
|
189
189
|
this.router.get(`${basePath}/docs`, (c) => {
|
|
190
|
-
const openApiSpec = generateOpenApiSpec(this.config.collections || [],
|
|
190
|
+
const openApiSpec = generateOpenApiSpec(this.config.collections || [], {
|
|
191
|
+
basePath: this.config.basePath,
|
|
192
|
+
requireAuth: this.config.requireAuth ?? true
|
|
193
|
+
});
|
|
191
194
|
return c.json(openApiSpec);
|
|
192
195
|
});
|
|
193
196
|
|
|
@@ -234,7 +237,7 @@ export class RebaseApiServer {
|
|
|
234
237
|
/**
|
|
235
238
|
* Start the server (standalone mode) via @hono/node-server
|
|
236
239
|
*/
|
|
237
|
-
listen(port
|
|
240
|
+
listen(port = 3000, callback?: () => void): void {
|
|
238
241
|
serve({
|
|
239
242
|
fetch: this.app.fetch,
|
|
240
243
|
port
|
package/src/api/types.ts
CHANGED
|
@@ -74,7 +74,7 @@ export interface QueryOptions {
|
|
|
74
74
|
limit?: number;
|
|
75
75
|
offset?: number;
|
|
76
76
|
where?: Record<string, unknown>;
|
|
77
|
-
orderBy?: Array<{ field: string; direction:
|
|
77
|
+
orderBy?: Array<{ field: string; direction: "asc" | "desc" }>;
|
|
78
78
|
include?: string[];
|
|
79
79
|
/** Columns to return in the response (field-level selection) */
|
|
80
80
|
fields?: string[];
|
package/src/auth/admin-routes.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import { ApiError } from "../api/errors";
|
|
2
|
+
import { ApiError, errorHandler } from "../api/errors";
|
|
3
3
|
import type { AuthRepository } from "./interfaces";
|
|
4
|
-
import { requireAuth, requireAdmin } from "./middleware";
|
|
4
|
+
import { requireAuth, requireAdmin, createRequireAuth } from "./middleware";
|
|
5
5
|
import { hashPassword, validatePasswordStrength } from "./password";
|
|
6
6
|
import { AuthModuleConfig } from "./routes";
|
|
7
|
+
|
|
8
|
+
interface AdminRouteOptions extends AuthModuleConfig {
|
|
9
|
+
serviceKey?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Callback to persistently mark bootstrap as completed.
|
|
12
|
+
* Invoked after the first admin user is promoted via POST /admin/bootstrap.
|
|
13
|
+
*/
|
|
14
|
+
setBootstrapCompleted?: () => Promise<void>;
|
|
15
|
+
}
|
|
7
16
|
import { HonoEnv } from "../api/types";
|
|
8
17
|
import { randomBytes, createHash } from "crypto";
|
|
9
18
|
import { getUserInvitationTemplate, getPasswordResetTemplate } from "../email/templates";
|
|
@@ -51,20 +60,45 @@ function hashToken(token: string): string {
|
|
|
51
60
|
/**
|
|
52
61
|
* Create admin routes for user and role management
|
|
53
62
|
*/
|
|
54
|
-
export function createAdminRoutes(config:
|
|
63
|
+
export function createAdminRoutes(config: AdminRouteOptions): Hono<HonoEnv> {
|
|
55
64
|
const router = new Hono<HonoEnv>();
|
|
56
65
|
const authRepo = config.authRepo;
|
|
57
66
|
const { emailService, emailConfig } = config;
|
|
58
67
|
|
|
59
|
-
//
|
|
60
|
-
router.
|
|
61
|
-
|
|
68
|
+
// Attach Rebase error handler to ensure exceptions are correctly formatted
|
|
69
|
+
// instead of caught by Hono's default error handler from the sub-router.
|
|
70
|
+
router.onError(errorHandler);
|
|
71
|
+
|
|
72
|
+
// Apply auth middleware to all routes (service-key-aware when configured)
|
|
73
|
+
router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* POST /admin/bootstrap
|
|
77
|
+
*
|
|
78
|
+
* One-time endpoint to promote the calling user to admin.
|
|
79
|
+
* Guarded by three layers:
|
|
80
|
+
* 1. Authentication (handled by middleware above)
|
|
81
|
+
* 2. Persistent `bootstrap_completed` flag (when `setBootstrapCompleted` is provided)
|
|
82
|
+
* 3. Database check — no existing admin users
|
|
83
|
+
*
|
|
84
|
+
* Once invoked successfully the persistent flag is set, permanently disabling
|
|
85
|
+
* this endpoint even if all admin users are later deleted.
|
|
86
|
+
*/
|
|
62
87
|
router.post("/bootstrap", async (c) => {
|
|
63
88
|
const user = c.get("user");
|
|
64
89
|
if (!user || typeof user !== "object") {
|
|
65
90
|
throw ApiError.unauthorized("Not authenticated");
|
|
66
91
|
}
|
|
67
92
|
|
|
93
|
+
// ── Guard 1: persistent flag ──────────────────────────────────
|
|
94
|
+
if (config.isBootstrapCompleted) {
|
|
95
|
+
const alreadyDone = await config.isBootstrapCompleted();
|
|
96
|
+
if (alreadyDone) {
|
|
97
|
+
throw ApiError.forbidden("Bootstrap has already been completed.", "BOOTSTRAP_COMPLETED");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Guard 2: no existing admin users ─────────────────────────
|
|
68
102
|
const users = await authRepo.listUsers();
|
|
69
103
|
let hasAdmin = false;
|
|
70
104
|
|
|
@@ -77,15 +111,21 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
77
111
|
}
|
|
78
112
|
|
|
79
113
|
if (hasAdmin) {
|
|
80
|
-
throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.");
|
|
114
|
+
throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.", "BOOTSTRAP_COMPLETED");
|
|
81
115
|
}
|
|
82
116
|
|
|
117
|
+
// ── Promote caller ───────────────────────────────────────────
|
|
83
118
|
const userId = "userId" in user ? user.userId : undefined;
|
|
84
119
|
if (!userId) {
|
|
85
120
|
throw ApiError.unauthorized("User ID not found in auth context");
|
|
86
121
|
}
|
|
87
122
|
await authRepo.setUserRoles(userId, ["admin"]);
|
|
88
123
|
|
|
124
|
+
// ── Set persistent flag ──────────────────────────────────────
|
|
125
|
+
if (config.setBootstrapCompleted) {
|
|
126
|
+
await config.setBootstrapCompleted();
|
|
127
|
+
}
|
|
128
|
+
|
|
89
129
|
return c.json({
|
|
90
130
|
success: true,
|
|
91
131
|
message: "You are now an admin",
|
|
@@ -113,7 +153,8 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
113
153
|
offset,
|
|
114
154
|
search: search || undefined,
|
|
115
155
|
orderBy: orderBy || undefined,
|
|
116
|
-
orderDir: orderDir || undefined
|
|
156
|
+
orderDir: orderDir || undefined,
|
|
157
|
+
roleId: c.req.query("role") || undefined
|
|
117
158
|
});
|
|
118
159
|
|
|
119
160
|
const usersWithRoles = await Promise.all(
|
|
@@ -124,7 +165,6 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
124
165
|
email: u.email,
|
|
125
166
|
displayName: u.displayName,
|
|
126
167
|
photoURL: u.photoUrl,
|
|
127
|
-
provider: u.provider,
|
|
128
168
|
roles,
|
|
129
169
|
createdAt: u.createdAt,
|
|
130
170
|
updatedAt: u.updatedAt
|
|
@@ -150,7 +190,6 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
150
190
|
email: u.email,
|
|
151
191
|
displayName: u.displayName,
|
|
152
192
|
photoURL: u.photoUrl,
|
|
153
|
-
provider: u.provider,
|
|
154
193
|
roles,
|
|
155
194
|
createdAt: u.createdAt,
|
|
156
195
|
updatedAt: u.updatedAt
|
|
@@ -174,7 +213,6 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
174
213
|
email: result.user.email,
|
|
175
214
|
displayName: result.user.displayName,
|
|
176
215
|
photoURL: result.user.photoUrl,
|
|
177
|
-
provider: result.user.provider,
|
|
178
216
|
roles: result.roles.map(r => r.id),
|
|
179
217
|
createdAt: result.user.createdAt,
|
|
180
218
|
updatedAt: result.user.updatedAt
|
|
@@ -207,8 +245,7 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
207
245
|
const user = await authRepo.createUser({
|
|
208
246
|
email: email.toLowerCase(),
|
|
209
247
|
displayName: displayName || null,
|
|
210
|
-
passwordHash
|
|
211
|
-
provider: "email"
|
|
248
|
+
passwordHash
|
|
212
249
|
});
|
|
213
250
|
|
|
214
251
|
if (roles && Array.isArray(roles) && roles.length > 0) {
|
|
@@ -239,8 +276,10 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
239
276
|
const appName = emailConfig?.appName || "Rebase";
|
|
240
277
|
const templateFn = emailConfig?.templates?.userInvitation;
|
|
241
278
|
const emailContent = templateFn
|
|
242
|
-
? templateFn(setPasswordUrl, { email: user.email,
|
|
243
|
-
|
|
279
|
+
? templateFn(setPasswordUrl, { email: user.email,
|
|
280
|
+
displayName: user.displayName })
|
|
281
|
+
: getUserInvitationTemplate(setPasswordUrl, { email: user.email,
|
|
282
|
+
displayName: user.displayName }, appName);
|
|
244
283
|
|
|
245
284
|
await emailService!.send({
|
|
246
285
|
to: user.email,
|
|
@@ -297,8 +336,10 @@ export function createAdminRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
297
336
|
const appName = emailConfig?.appName || "Rebase";
|
|
298
337
|
const templateFn = emailConfig?.templates?.passwordReset;
|
|
299
338
|
const emailContent = templateFn
|
|
300
|
-
? templateFn(setPasswordUrl, { email: existing.email,
|
|
301
|
-
|
|
339
|
+
? templateFn(setPasswordUrl, { email: existing.email,
|
|
340
|
+
displayName: existing.displayName })
|
|
341
|
+
: getPasswordResetTemplate(setPasswordUrl, { email: existing.email,
|
|
342
|
+
displayName: existing.displayName }, appName);
|
|
302
343
|
|
|
303
344
|
await emailService!.send({
|
|
304
345
|
to: existing.email,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { OAuthProvider, OAuthProviderProfile } from "./interfaces";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createPrivateKey } from "crypto";
|
|
4
|
+
import { SignJWT } from "jose";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates an Apple Sign In OAuth Provider integration.
|
|
8
|
+
*
|
|
9
|
+
* Apple requires a client secret that is a signed JWT, regenerated on each
|
|
10
|
+
* token exchange (valid up to 6 months). This provider handles that automatically.
|
|
11
|
+
*
|
|
12
|
+
* Required Apple Developer configuration:
|
|
13
|
+
* - Services ID (clientId)
|
|
14
|
+
* - Key ID from the private key registered with Apple
|
|
15
|
+
* - Team ID from Apple Developer account
|
|
16
|
+
* - Private key (.p8 file contents) downloaded from Apple Developer portal
|
|
17
|
+
*/
|
|
18
|
+
export function createAppleProvider(config: {
|
|
19
|
+
clientId: string;
|
|
20
|
+
teamId: string;
|
|
21
|
+
keyId: string;
|
|
22
|
+
/** The raw PEM contents of the .p8 private key file */
|
|
23
|
+
privateKey: string;
|
|
24
|
+
}): OAuthProvider<{
|
|
25
|
+
code: string;
|
|
26
|
+
redirectUri: string;
|
|
27
|
+
user?: { name?: { firstName?: string; lastName?: string }; email?: string };
|
|
28
|
+
}> {
|
|
29
|
+
/**
|
|
30
|
+
* Generate a client_secret JWT signed with the Apple private key.
|
|
31
|
+
* Apple requires this instead of a static client_secret.
|
|
32
|
+
*/
|
|
33
|
+
async function generateClientSecret(): Promise<string> {
|
|
34
|
+
const key = createPrivateKey({
|
|
35
|
+
key: config.privateKey,
|
|
36
|
+
format: "pem"
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const now = Math.floor(Date.now() / 1000);
|
|
40
|
+
|
|
41
|
+
return new SignJWT({})
|
|
42
|
+
.setProtectedHeader({ alg: "ES256",
|
|
43
|
+
kid: config.keyId })
|
|
44
|
+
.setIssuer(config.teamId)
|
|
45
|
+
.setIssuedAt(now)
|
|
46
|
+
.setExpirationTime(now + 86400 * 180) // 6 months max
|
|
47
|
+
.setAudience("https://appleid.apple.com")
|
|
48
|
+
.setSubject(config.clientId)
|
|
49
|
+
.sign(key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
id: "apple",
|
|
54
|
+
schema: z.object({
|
|
55
|
+
code: z.string().min(1, "Auth code is required"),
|
|
56
|
+
redirectUri: z.string().url("Valid redirect URI is required"),
|
|
57
|
+
/** Apple sends user info only on first authorization; the frontend must forward it. */
|
|
58
|
+
user: z.object({
|
|
59
|
+
name: z.object({
|
|
60
|
+
firstName: z.string().optional(),
|
|
61
|
+
lastName: z.string().optional()
|
|
62
|
+
}).optional(),
|
|
63
|
+
email: z.string().email().optional()
|
|
64
|
+
}).optional()
|
|
65
|
+
}),
|
|
66
|
+
verify: async (payload: {
|
|
67
|
+
code: string;
|
|
68
|
+
redirectUri: string;
|
|
69
|
+
user?: { name?: { firstName?: string; lastName?: string }; email?: string };
|
|
70
|
+
}): Promise<OAuthProviderProfile | null> => {
|
|
71
|
+
try {
|
|
72
|
+
const clientSecret = await generateClientSecret();
|
|
73
|
+
|
|
74
|
+
// Exchange code for tokens
|
|
75
|
+
const tokenResponse = await fetch("https://appleid.apple.com/auth/token", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
78
|
+
body: new URLSearchParams({
|
|
79
|
+
client_id: config.clientId,
|
|
80
|
+
client_secret: clientSecret,
|
|
81
|
+
code: payload.code,
|
|
82
|
+
grant_type: "authorization_code",
|
|
83
|
+
redirect_uri: payload.redirectUri
|
|
84
|
+
})
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!tokenResponse.ok) {
|
|
88
|
+
console.error("Failed to get Apple access token:", await tokenResponse.text());
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const tokenData = await tokenResponse.json() as { id_token: string };
|
|
93
|
+
|
|
94
|
+
// Decode the id_token (JWT) to get user info.
|
|
95
|
+
// Apple's id_token is a standard JWT — we only need the payload.
|
|
96
|
+
const [, payloadB64] = tokenData.id_token.split(".");
|
|
97
|
+
const decoded = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8")) as {
|
|
98
|
+
sub: string;
|
|
99
|
+
email?: string;
|
|
100
|
+
email_verified?: string | boolean;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Apple only sends the user's name on the FIRST authorization.
|
|
104
|
+
// Subsequent logins only have the id_token. The frontend should pass
|
|
105
|
+
// the user object from the first auth for us to capture the name.
|
|
106
|
+
const email = decoded.email || payload.user?.email;
|
|
107
|
+
if (!email) {
|
|
108
|
+
console.error("Apple user has no email");
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let displayName: string | null = null;
|
|
113
|
+
if (payload.user?.name) {
|
|
114
|
+
const parts = [payload.user.name.firstName, payload.user.name.lastName].filter(Boolean);
|
|
115
|
+
displayName = parts.length > 0 ? parts.join(" ") : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
providerId: decoded.sub,
|
|
120
|
+
email,
|
|
121
|
+
displayName,
|
|
122
|
+
photoUrl: null // Apple does not provide a profile photo
|
|
123
|
+
};
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error("Apple OAuth error:", error);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { OAuthProvider, OAuthProviderProfile } from "./interfaces";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a Bitbucket OAuth Provider integration (OAuth 2.0 consumer).
|
|
6
|
+
*/
|
|
7
|
+
export function createBitbucketProvider(config: { clientId: string; clientSecret: string }): OAuthProvider<{ code: string; redirectUri: string }> {
|
|
8
|
+
return {
|
|
9
|
+
id: "bitbucket",
|
|
10
|
+
schema: z.object({
|
|
11
|
+
code: z.string().min(1, "Auth code is required"),
|
|
12
|
+
redirectUri: z.string().url("Valid redirect URI is required")
|
|
13
|
+
}),
|
|
14
|
+
verify: async (payload: { code: string; redirectUri: string }): Promise<OAuthProviderProfile | null> => {
|
|
15
|
+
try {
|
|
16
|
+
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
|
17
|
+
|
|
18
|
+
const tokenResponse = await fetch("https://bitbucket.org/site/oauth2/access_token", {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: {
|
|
21
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
22
|
+
"Authorization": `Basic ${basicAuth}`
|
|
23
|
+
},
|
|
24
|
+
body: new URLSearchParams({
|
|
25
|
+
grant_type: "authorization_code",
|
|
26
|
+
code: payload.code,
|
|
27
|
+
redirect_uri: payload.redirectUri
|
|
28
|
+
})
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!tokenResponse.ok) {
|
|
32
|
+
console.error("Failed to get Bitbucket access token:", await tokenResponse.text());
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const tokenData = await tokenResponse.json() as { access_token: string };
|
|
37
|
+
const accessToken = tokenData.access_token;
|
|
38
|
+
|
|
39
|
+
const profileResponse = await fetch("https://api.bitbucket.org/2.0/user", {
|
|
40
|
+
headers: { "Authorization": `Bearer ${accessToken}` }
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!profileResponse.ok) {
|
|
44
|
+
console.error("Failed to get Bitbucket user info:", await profileResponse.text());
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const p = await profileResponse.json() as {
|
|
49
|
+
uuid: string; display_name?: string; username?: string;
|
|
50
|
+
links?: { avatar?: { href?: string } };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Bitbucket doesn't return email in user profile — fetch from /emails endpoint
|
|
54
|
+
const emailsResponse = await fetch("https://api.bitbucket.org/2.0/user/emails", {
|
|
55
|
+
headers: { "Authorization": `Bearer ${accessToken}` }
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
let email: string | null = null;
|
|
59
|
+
if (emailsResponse.ok) {
|
|
60
|
+
const emailData = await emailsResponse.json() as {
|
|
61
|
+
values: Array<{ email: string; is_primary: boolean; is_confirmed: boolean }>;
|
|
62
|
+
};
|
|
63
|
+
const primary = emailData.values.find(e => e.is_primary && e.is_confirmed)
|
|
64
|
+
|| emailData.values.find(e => e.is_confirmed);
|
|
65
|
+
email = primary?.email || null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!email) { console.error("Bitbucket user has no verified email"); return null; }
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
providerId: p.uuid,
|
|
72
|
+
email,
|
|
73
|
+
displayName: p.display_name || p.username || null,
|
|
74
|
+
photoUrl: p.links?.avatar?.href || null
|
|
75
|
+
};
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error("Bitbucket OAuth error:", error);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { OAuthProvider, OAuthProviderProfile } from "./interfaces";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a Discord OAuth2 Provider integration.
|
|
6
|
+
*
|
|
7
|
+
* Uses the authorization code flow. Requires the "identify" and "email"
|
|
8
|
+
* scopes to retrieve the user's email and profile information.
|
|
9
|
+
*/
|
|
10
|
+
export function createDiscordProvider(config: { clientId: string; clientSecret: string }): OAuthProvider<{ code: string; redirectUri: string }> {
|
|
11
|
+
return {
|
|
12
|
+
id: "discord",
|
|
13
|
+
schema: z.object({
|
|
14
|
+
code: z.string().min(1, "Auth code is required"),
|
|
15
|
+
redirectUri: z.string().url("Valid redirect URI is required")
|
|
16
|
+
}),
|
|
17
|
+
verify: async (payload: { code: string; redirectUri: string }): Promise<OAuthProviderProfile | null> => {
|
|
18
|
+
try {
|
|
19
|
+
// Exchange code for access token
|
|
20
|
+
const tokenResponse = await fetch("https://discord.com/api/v10/oauth2/token", {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
23
|
+
body: new URLSearchParams({
|
|
24
|
+
client_id: config.clientId,
|
|
25
|
+
client_secret: config.clientSecret,
|
|
26
|
+
grant_type: "authorization_code",
|
|
27
|
+
code: payload.code,
|
|
28
|
+
redirect_uri: payload.redirectUri
|
|
29
|
+
})
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!tokenResponse.ok) {
|
|
33
|
+
console.error("Failed to get Discord access token:", await tokenResponse.text());
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tokenData = await tokenResponse.json() as { access_token: string };
|
|
38
|
+
const accessToken = tokenData.access_token;
|
|
39
|
+
|
|
40
|
+
// Fetch user profile
|
|
41
|
+
const profileResponse = await fetch("https://discord.com/api/v10/users/@me", {
|
|
42
|
+
headers: { "Authorization": `Bearer ${accessToken}` }
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (!profileResponse.ok) {
|
|
46
|
+
console.error("Failed to get Discord user info:", await profileResponse.text());
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const profileData = await profileResponse.json() as {
|
|
51
|
+
id: string;
|
|
52
|
+
username: string;
|
|
53
|
+
global_name?: string | null;
|
|
54
|
+
avatar?: string | null;
|
|
55
|
+
email?: string | null;
|
|
56
|
+
verified?: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (!profileData.email) {
|
|
60
|
+
console.error("Discord user has no email (email scope may not have been granted)");
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Build avatar URL
|
|
65
|
+
let photoUrl: string | null = null;
|
|
66
|
+
if (profileData.avatar) {
|
|
67
|
+
const ext = profileData.avatar.startsWith("a_") ? "gif" : "png";
|
|
68
|
+
photoUrl = `https://cdn.discordapp.com/avatars/${profileData.id}/${profileData.avatar}.${ext}?size=256`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
providerId: profileData.id,
|
|
73
|
+
email: profileData.email,
|
|
74
|
+
displayName: profileData.global_name || profileData.username || null,
|
|
75
|
+
photoUrl
|
|
76
|
+
};
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error("Discord OAuth error:", error);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|