@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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { OAuthProvider, OAuthProviderProfile } from "./interfaces";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a Twitter/X OAuth 2.0 Provider integration.
|
|
6
|
+
*
|
|
7
|
+
* Uses OAuth 2.0 with PKCE (authorization code flow). The frontend must include
|
|
8
|
+
* the PKCE `code_verifier` when sending the authorization code.
|
|
9
|
+
*
|
|
10
|
+
* Twitter API v2 requires the "tweet.read" and "users.read" scopes at minimum,
|
|
11
|
+
* plus "offline.access" if refresh tokens are needed on Twitter's side.
|
|
12
|
+
*/
|
|
13
|
+
export function createTwitterProvider(config: { clientId: string; clientSecret: string }): OAuthProvider<{
|
|
14
|
+
code: string;
|
|
15
|
+
redirectUri: string;
|
|
16
|
+
codeVerifier: string;
|
|
17
|
+
}> {
|
|
18
|
+
return {
|
|
19
|
+
id: "twitter",
|
|
20
|
+
schema: z.object({
|
|
21
|
+
code: z.string().min(1, "Auth code is required"),
|
|
22
|
+
redirectUri: z.string().url("Valid redirect URI is required"),
|
|
23
|
+
codeVerifier: z.string().min(1, "PKCE code verifier is required")
|
|
24
|
+
}),
|
|
25
|
+
verify: async (payload: {
|
|
26
|
+
code: string;
|
|
27
|
+
redirectUri: string;
|
|
28
|
+
codeVerifier: string;
|
|
29
|
+
}): Promise<OAuthProviderProfile | null> => {
|
|
30
|
+
try {
|
|
31
|
+
// Twitter OAuth 2.0 uses Basic auth for the token endpoint
|
|
32
|
+
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
|
33
|
+
|
|
34
|
+
// Exchange code for access token
|
|
35
|
+
const tokenResponse = await fetch("https://api.twitter.com/2/oauth2/token", {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: {
|
|
38
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
39
|
+
"Authorization": `Basic ${basicAuth}`
|
|
40
|
+
},
|
|
41
|
+
body: new URLSearchParams({
|
|
42
|
+
code: payload.code,
|
|
43
|
+
grant_type: "authorization_code",
|
|
44
|
+
redirect_uri: payload.redirectUri,
|
|
45
|
+
code_verifier: payload.codeVerifier
|
|
46
|
+
})
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!tokenResponse.ok) {
|
|
50
|
+
console.error("Failed to get Twitter access token:", await tokenResponse.text());
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tokenData = await tokenResponse.json() as { access_token: string };
|
|
55
|
+
const accessToken = tokenData.access_token;
|
|
56
|
+
|
|
57
|
+
// Fetch user profile from Twitter API v2
|
|
58
|
+
const profileResponse = await fetch(
|
|
59
|
+
"https://api.twitter.com/2/users/me?user.fields=id,name,username,profile_image_url",
|
|
60
|
+
{
|
|
61
|
+
headers: { "Authorization": `Bearer ${accessToken}` }
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (!profileResponse.ok) {
|
|
66
|
+
console.error("Failed to get Twitter user info:", await profileResponse.text());
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const profileResult = await profileResponse.json() as {
|
|
71
|
+
data: {
|
|
72
|
+
id: string;
|
|
73
|
+
name: string;
|
|
74
|
+
username: string;
|
|
75
|
+
profile_image_url?: string;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const profileData = profileResult.data;
|
|
80
|
+
|
|
81
|
+
// Twitter does NOT expose email via API v2 by default.
|
|
82
|
+
// It requires elevated access. If we can't get an email, we
|
|
83
|
+
// generate a placeholder email — the user can update it later.
|
|
84
|
+
// For apps with elevated access, fetch from v1.1 endpoint.
|
|
85
|
+
let email: string | null = null;
|
|
86
|
+
try {
|
|
87
|
+
const emailResponse = await fetch(
|
|
88
|
+
"https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true",
|
|
89
|
+
{
|
|
90
|
+
headers: { "Authorization": `Bearer ${accessToken}` }
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
if (emailResponse.ok) {
|
|
94
|
+
const emailData = await emailResponse.json() as { email?: string };
|
|
95
|
+
email = emailData.email || null;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Elevated access not available — fall through
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!email) {
|
|
102
|
+
// Construct a deterministic placeholder email from the Twitter user ID.
|
|
103
|
+
// This allows the account to be created and linked; the user should
|
|
104
|
+
// update their email through the profile settings.
|
|
105
|
+
email = `${profileData.id}@twitter.placeholder.rebase`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
providerId: profileData.id,
|
|
110
|
+
email,
|
|
111
|
+
displayName: profileData.name || profileData.username || null,
|
|
112
|
+
photoUrl: profileData.profile_image_url?.replace("_normal", "_400x400") || null
|
|
113
|
+
};
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error("Twitter OAuth error:", error);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -17,19 +17,19 @@ export async function loadCollectionsFromDirectory(directory: string): Promise<E
|
|
|
17
17
|
const files = fs.readdirSync(directory);
|
|
18
18
|
for (const file of files) {
|
|
19
19
|
// Only load .ts and .js files, ignore test files and declaration files
|
|
20
|
-
if ((file.endsWith(
|
|
21
|
-
!file.includes(
|
|
22
|
-
!file.endsWith(
|
|
23
|
-
file !==
|
|
20
|
+
if ((file.endsWith(".ts") || file.endsWith(".js")) &&
|
|
21
|
+
!file.includes(".test.") &&
|
|
22
|
+
!file.endsWith(".d.ts") &&
|
|
23
|
+
file !== "index.ts" && file !== "index.js") {
|
|
24
24
|
|
|
25
25
|
const filePath = path.join(directory, file);
|
|
26
26
|
try {
|
|
27
27
|
const fileUrl = pathToFileURL(filePath).href;
|
|
28
28
|
|
|
29
29
|
// Use new Function to compile dynamic import natively and bypass tsc converting import() to require()
|
|
30
|
-
const dynamicImport = new Function(
|
|
30
|
+
const dynamicImport = new Function("url", "return import(url)");
|
|
31
31
|
const module = await dynamicImport(fileUrl);
|
|
32
|
-
|
|
32
|
+
|
|
33
33
|
// Expect the collection to be the default export
|
|
34
34
|
if (module && module.default) {
|
|
35
35
|
collections.push(module.default);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { pathToFileURL } from "url";
|
|
4
|
+
import type { CronJobDefinition } from "@rebasepro/types";
|
|
5
|
+
|
|
6
|
+
export interface LoadedCronJob {
|
|
7
|
+
/** Job ID derived from filename (e.g. "cleanup-sessions"). */
|
|
8
|
+
id: string;
|
|
9
|
+
/** The full definition. */
|
|
10
|
+
definition: CronJobDefinition;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Auto-discover cron job files from a directory.
|
|
15
|
+
*
|
|
16
|
+
* Each file should default-export a `CronJobDefinition`.
|
|
17
|
+
* The filename (without extension) becomes the job ID:
|
|
18
|
+
* `crons/cleanup-sessions.ts` → id = "cleanup-sessions"
|
|
19
|
+
*
|
|
20
|
+
* Follows the same discovery pattern as `loadFunctionsFromDirectory`.
|
|
21
|
+
*/
|
|
22
|
+
export async function loadCronJobsFromDirectory(
|
|
23
|
+
directory: string
|
|
24
|
+
): Promise<LoadedCronJob[]> {
|
|
25
|
+
const jobs: LoadedCronJob[] = [];
|
|
26
|
+
|
|
27
|
+
if (!fs.existsSync(directory)) {
|
|
28
|
+
return jobs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const files = fs.readdirSync(directory);
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
if (
|
|
34
|
+
(file.endsWith(".ts") || file.endsWith(".js")) &&
|
|
35
|
+
!file.includes(".test.") &&
|
|
36
|
+
!file.endsWith(".d.ts") &&
|
|
37
|
+
file !== "index.ts" &&
|
|
38
|
+
file !== "index.js"
|
|
39
|
+
) {
|
|
40
|
+
const filePath = path.join(directory, file);
|
|
41
|
+
try {
|
|
42
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
43
|
+
|
|
44
|
+
// Native dynamic import — bypasses tsc down-compilation
|
|
45
|
+
const dynamicImport = new Function("url", "return import(url)");
|
|
46
|
+
const mod = await dynamicImport(fileUrl);
|
|
47
|
+
|
|
48
|
+
const exported: unknown = mod.default;
|
|
49
|
+
|
|
50
|
+
if (!exported || typeof exported !== "object") {
|
|
51
|
+
console.warn(
|
|
52
|
+
`[cron] ${file}: no valid default export. Skipping.`
|
|
53
|
+
);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const def = exported as Record<string, unknown>;
|
|
58
|
+
if (typeof def.schedule !== "string" || typeof def.handler !== "function") {
|
|
59
|
+
console.warn(
|
|
60
|
+
`[cron] ${file}: default export missing required 'schedule' or 'handler'. Skipping.`
|
|
61
|
+
);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const id = path.basename(file, path.extname(file));
|
|
66
|
+
const definition: CronJobDefinition = {
|
|
67
|
+
schedule: def.schedule as string,
|
|
68
|
+
name: (def.name as string) || id,
|
|
69
|
+
description: def.description as string | undefined,
|
|
70
|
+
enabled: def.enabled !== false,
|
|
71
|
+
timeoutSeconds: (def.timeoutSeconds as number) || 300,
|
|
72
|
+
handler: def.handler as CronJobDefinition["handler"]
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
jobs.push({ id,
|
|
76
|
+
definition });
|
|
77
|
+
console.log(`⏰ Loaded cron job: ${id} (${definition.schedule})`);
|
|
78
|
+
} catch (err: unknown) {
|
|
79
|
+
const message =
|
|
80
|
+
err instanceof Error ? err.message : String(err);
|
|
81
|
+
console.error(
|
|
82
|
+
`[cron] Failed to load ${file}: ${message}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return jobs;
|
|
89
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "@jest/globals";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { CronScheduler } from "./cron-scheduler";
|
|
4
|
+
import { createCronRoutes } from "./cron-routes";
|
|
5
|
+
import type { LoadedCronJob } from "./cron-loader";
|
|
6
|
+
import type { CronJobDefinition } from "@rebasepro/types";
|
|
7
|
+
|
|
8
|
+
// ─── Helpers ────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function makeJob(
|
|
11
|
+
id: string,
|
|
12
|
+
overrides: Partial<CronJobDefinition> = {}
|
|
13
|
+
): LoadedCronJob {
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
definition: {
|
|
17
|
+
schedule: "0 * * * *",
|
|
18
|
+
name: `Job ${id}`,
|
|
19
|
+
enabled: true,
|
|
20
|
+
timeoutSeconds: 5,
|
|
21
|
+
handler: async (ctx) => {
|
|
22
|
+
ctx.log("executed");
|
|
23
|
+
return { ran: true };
|
|
24
|
+
},
|
|
25
|
+
...overrides
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function jsonBody(res: Response): Promise<Record<string, unknown>> {
|
|
31
|
+
return res.json() as Promise<Record<string, unknown>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Tests ──────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
describe("cron-routes", () => {
|
|
37
|
+
let scheduler: CronScheduler;
|
|
38
|
+
let app: Hono;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
scheduler = new CronScheduler();
|
|
42
|
+
scheduler.registerJobs([
|
|
43
|
+
makeJob("job-a", { name: "Job A",
|
|
44
|
+
description: "First job" }),
|
|
45
|
+
makeJob("job-b", { name: "Job B" })
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
app = new Hono();
|
|
49
|
+
app.route("/cron", createCronRoutes(scheduler));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ── GET / (list) ────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe("GET /cron", () => {
|
|
55
|
+
it("returns all registered jobs", async () => {
|
|
56
|
+
const res = await app.request("/cron");
|
|
57
|
+
expect(res.status).toBe(200);
|
|
58
|
+
|
|
59
|
+
const body = await jsonBody(res);
|
|
60
|
+
const jobs = body.jobs as Array<Record<string, unknown>>;
|
|
61
|
+
expect(jobs).toHaveLength(2);
|
|
62
|
+
expect(jobs.map((j) => j.id).sort()).toEqual(["job-a", "job-b"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("each job has the expected shape", async () => {
|
|
66
|
+
const res = await app.request("/cron");
|
|
67
|
+
const body = await jsonBody(res);
|
|
68
|
+
const jobs = body.jobs as Array<Record<string, unknown>>;
|
|
69
|
+
const jobA = jobs.find((j) => j.id === "job-a")!;
|
|
70
|
+
|
|
71
|
+
expect(jobA.name).toBe("Job A");
|
|
72
|
+
expect(jobA.description).toBe("First job");
|
|
73
|
+
expect(jobA.schedule).toBe("0 * * * *");
|
|
74
|
+
expect(jobA.enabled).toBe(true);
|
|
75
|
+
expect(jobA.state).toBe("idle");
|
|
76
|
+
expect(jobA.totalRuns).toBe(0);
|
|
77
|
+
expect(jobA.totalFailures).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ── GET /:id (single) ───────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe("GET /cron/:id", () => {
|
|
84
|
+
it("returns a single job", async () => {
|
|
85
|
+
const res = await app.request("/cron/job-a");
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
|
|
88
|
+
const body = await jsonBody(res);
|
|
89
|
+
const job = body.job as Record<string, unknown>;
|
|
90
|
+
expect(job.id).toBe("job-a");
|
|
91
|
+
expect(job.name).toBe("Job A");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns 404 for nonexistent job", async () => {
|
|
95
|
+
const res = await app.request("/cron/nonexistent");
|
|
96
|
+
expect(res.status).toBe(404);
|
|
97
|
+
|
|
98
|
+
const body = await jsonBody(res);
|
|
99
|
+
expect((body.error as Record<string, unknown>).code).toBe("NOT_FOUND");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── POST /:id/trigger ───────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
describe("POST /cron/:id/trigger", () => {
|
|
106
|
+
it("triggers a job and returns log + updated status", async () => {
|
|
107
|
+
const res = await app.request("/cron/job-a/trigger", {
|
|
108
|
+
method: "POST"
|
|
109
|
+
});
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
|
|
112
|
+
const body = await jsonBody(res);
|
|
113
|
+
const log = body.log as Record<string, unknown>;
|
|
114
|
+
const job = body.job as Record<string, unknown>;
|
|
115
|
+
|
|
116
|
+
expect(log.jobId).toBe("job-a");
|
|
117
|
+
expect(log.success).toBe(true);
|
|
118
|
+
expect(log.manual).toBe(true);
|
|
119
|
+
expect((log.logs as string[])).toContain("executed");
|
|
120
|
+
expect(log.result).toEqual({ ran: true });
|
|
121
|
+
|
|
122
|
+
expect(job.totalRuns).toBe(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns 404 for nonexistent job", async () => {
|
|
126
|
+
const res = await app.request("/cron/ghost/trigger", {
|
|
127
|
+
method: "POST"
|
|
128
|
+
});
|
|
129
|
+
expect(res.status).toBe(404);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── GET /:id/logs ───────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe("GET /cron/:id/logs", () => {
|
|
136
|
+
it("returns empty logs before any execution", async () => {
|
|
137
|
+
const res = await app.request("/cron/job-a/logs");
|
|
138
|
+
expect(res.status).toBe(200);
|
|
139
|
+
|
|
140
|
+
const body = await jsonBody(res);
|
|
141
|
+
expect(body.logs).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("returns logs after trigger", async () => {
|
|
145
|
+
// Trigger first
|
|
146
|
+
await app.request("/cron/job-a/trigger", { method: "POST" });
|
|
147
|
+
|
|
148
|
+
const res = await app.request("/cron/job-a/logs");
|
|
149
|
+
expect(res.status).toBe(200);
|
|
150
|
+
|
|
151
|
+
const body = await jsonBody(res);
|
|
152
|
+
const logs = body.logs as Array<Record<string, unknown>>;
|
|
153
|
+
expect(logs).toHaveLength(1);
|
|
154
|
+
expect(logs[0].success).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("respects limit query parameter", async () => {
|
|
158
|
+
await app.request("/cron/job-a/trigger", { method: "POST" });
|
|
159
|
+
await app.request("/cron/job-a/trigger", { method: "POST" });
|
|
160
|
+
await app.request("/cron/job-a/trigger", { method: "POST" });
|
|
161
|
+
|
|
162
|
+
const res = await app.request("/cron/job-a/logs?limit=2");
|
|
163
|
+
const body = await jsonBody(res);
|
|
164
|
+
expect((body.logs as unknown[]).length).toBe(2);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("returns 404 for nonexistent job", async () => {
|
|
168
|
+
const res = await app.request("/cron/ghost/logs");
|
|
169
|
+
expect(res.status).toBe(404);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── PUT /:id (enable/disable) ───────────────────────────────────
|
|
174
|
+
|
|
175
|
+
describe("PUT /cron/:id", () => {
|
|
176
|
+
it("disables a job", async () => {
|
|
177
|
+
const res = await app.request("/cron/job-a", {
|
|
178
|
+
method: "PUT",
|
|
179
|
+
headers: { "Content-Type": "application/json" },
|
|
180
|
+
body: JSON.stringify({ enabled: false })
|
|
181
|
+
});
|
|
182
|
+
expect(res.status).toBe(200);
|
|
183
|
+
|
|
184
|
+
const body = await jsonBody(res);
|
|
185
|
+
const job = body.job as Record<string, unknown>;
|
|
186
|
+
expect(job.enabled).toBe(false);
|
|
187
|
+
expect(job.state).toBe("disabled");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("re-enables a disabled job", async () => {
|
|
191
|
+
// Disable first
|
|
192
|
+
await app.request("/cron/job-a", {
|
|
193
|
+
method: "PUT",
|
|
194
|
+
headers: { "Content-Type": "application/json" },
|
|
195
|
+
body: JSON.stringify({ enabled: false })
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Re-enable
|
|
199
|
+
const res = await app.request("/cron/job-a", {
|
|
200
|
+
method: "PUT",
|
|
201
|
+
headers: { "Content-Type": "application/json" },
|
|
202
|
+
body: JSON.stringify({ enabled: true })
|
|
203
|
+
});
|
|
204
|
+
expect(res.status).toBe(200);
|
|
205
|
+
|
|
206
|
+
const body = await jsonBody(res);
|
|
207
|
+
expect((body.job as Record<string, unknown>).enabled).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns 400 if enabled is missing from body", async () => {
|
|
211
|
+
const res = await app.request("/cron/job-a", {
|
|
212
|
+
method: "PUT",
|
|
213
|
+
headers: { "Content-Type": "application/json" },
|
|
214
|
+
body: JSON.stringify({ something: "else" })
|
|
215
|
+
});
|
|
216
|
+
expect(res.status).toBe(400);
|
|
217
|
+
|
|
218
|
+
const body = await jsonBody(res);
|
|
219
|
+
expect((body.error as Record<string, unknown>).code).toBe("BAD_REQUEST");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns 404 for nonexistent job", async () => {
|
|
223
|
+
const res = await app.request("/cron/ghost", {
|
|
224
|
+
method: "PUT",
|
|
225
|
+
headers: { "Content-Type": "application/json" },
|
|
226
|
+
body: JSON.stringify({ enabled: false })
|
|
227
|
+
});
|
|
228
|
+
expect(res.status).toBe(404);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ── Edge cases ──────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
describe("edge cases", () => {
|
|
235
|
+
it("handles a failing job trigger gracefully", async () => {
|
|
236
|
+
scheduler.registerJobs([
|
|
237
|
+
makeJob("fail-job", {
|
|
238
|
+
handler: async () => {
|
|
239
|
+
throw new Error("kaboom");
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
const res = await app.request("/cron/fail-job/trigger", {
|
|
245
|
+
method: "POST"
|
|
246
|
+
});
|
|
247
|
+
expect(res.status).toBe(200); // trigger still returns 200
|
|
248
|
+
|
|
249
|
+
const body = await jsonBody(res);
|
|
250
|
+
const log = body.log as Record<string, unknown>;
|
|
251
|
+
expect(log.success).toBe(false);
|
|
252
|
+
expect(log.error).toBe("kaboom");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("handles URL-encoded job IDs", async () => {
|
|
256
|
+
scheduler.registerJobs([makeJob("has-dash", { name: "Dashed" })]);
|
|
257
|
+
|
|
258
|
+
const res = await app.request("/cron/has-dash");
|
|
259
|
+
expect(res.status).toBe(200);
|
|
260
|
+
|
|
261
|
+
const body = await jsonBody(res);
|
|
262
|
+
expect((body.job as Record<string, unknown>).id).toBe("has-dash");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { HonoEnv } from "../api/types";
|
|
3
|
+
import type { CronScheduler } from "./cron-scheduler";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create admin REST routes for managing cron jobs.
|
|
7
|
+
*
|
|
8
|
+
* Routes:
|
|
9
|
+
* GET / → list all cron jobs
|
|
10
|
+
* GET /:id → get a single job's status
|
|
11
|
+
* POST /:id/trigger → manually trigger a job
|
|
12
|
+
* GET /:id/logs → get execution logs for a job
|
|
13
|
+
* PUT /:id → update job (enable/disable)
|
|
14
|
+
*/
|
|
15
|
+
export function createCronRoutes(scheduler: CronScheduler): Hono<HonoEnv> {
|
|
16
|
+
const router = new Hono<HonoEnv>();
|
|
17
|
+
|
|
18
|
+
// List all jobs
|
|
19
|
+
router.get("/", (c) => {
|
|
20
|
+
const jobs = scheduler.listJobs();
|
|
21
|
+
return c.json({ jobs });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Get single job
|
|
25
|
+
router.get("/:id", (c) => {
|
|
26
|
+
const id = c.req.param("id");
|
|
27
|
+
const job = scheduler.getJob(id);
|
|
28
|
+
if (!job) {
|
|
29
|
+
return c.json({ error: { message: `Cron job "${id}" not found`,
|
|
30
|
+
code: "NOT_FOUND" } }, 404);
|
|
31
|
+
}
|
|
32
|
+
return c.json({ job });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Trigger a job manually
|
|
36
|
+
router.post("/:id/trigger", async (c) => {
|
|
37
|
+
const id = c.req.param("id");
|
|
38
|
+
const job = scheduler.getJob(id);
|
|
39
|
+
if (!job) {
|
|
40
|
+
return c.json({ error: { message: `Cron job "${id}" not found`,
|
|
41
|
+
code: "NOT_FOUND" } }, 404);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const log = await scheduler.triggerJob(id);
|
|
45
|
+
return c.json({ log,
|
|
46
|
+
job: scheduler.getJob(id) });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Get job logs
|
|
50
|
+
router.get("/:id/logs", async (c) => {
|
|
51
|
+
const id = c.req.param("id");
|
|
52
|
+
const limitStr = c.req.query("limit");
|
|
53
|
+
const limit = limitStr ? parseInt(limitStr, 10) : undefined;
|
|
54
|
+
|
|
55
|
+
const job = scheduler.getJob(id);
|
|
56
|
+
if (!job) {
|
|
57
|
+
return c.json({ error: { message: `Cron job "${id}" not found`,
|
|
58
|
+
code: "NOT_FOUND" } }, 404);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const logs = await scheduler.getJobLogsFromDb(id, limit);
|
|
62
|
+
return c.json({ logs });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Enable/disable a job
|
|
66
|
+
router.put("/:id", async (c) => {
|
|
67
|
+
const id = c.req.param("id");
|
|
68
|
+
const body = await c.req.json().catch(() => ({})) as { enabled: boolean };
|
|
69
|
+
|
|
70
|
+
if (typeof body.enabled !== "boolean") {
|
|
71
|
+
return c.json({ error: { message: "Missing 'enabled' boolean in body",
|
|
72
|
+
code: "BAD_REQUEST" } }, 400);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const job = scheduler.setJobEnabled(id, body.enabled);
|
|
76
|
+
if (!job) {
|
|
77
|
+
return c.json({ error: { message: `Cron job "${id}" not found`,
|
|
78
|
+
code: "NOT_FOUND" } }, 404);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return c.json({ job });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return router;
|
|
85
|
+
}
|