@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.
Files changed (176) hide show
  1. package/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
  2. package/dist/common/src/util/entities.d.ts +22 -0
  3. package/dist/common/src/util/relations.d.ts +14 -4
  4. package/dist/common/src/util/resolutions.d.ts +1 -1
  5. package/dist/index-DXVBFp5V.js +37 -0
  6. package/dist/index-DXVBFp5V.js.map +1 -0
  7. package/dist/index.es.js +49931 -55
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/index.umd.js +28475 -34569
  10. package/dist/index.umd.js.map +1 -1
  11. package/dist/server-core/src/api/openapi-generator.d.ts +15 -1
  12. package/dist/server-core/src/api/types.d.ts +1 -1
  13. package/dist/server-core/src/auth/admin-routes.d.ts +10 -1
  14. package/dist/server-core/src/auth/apple-oauth.d.ts +30 -0
  15. package/dist/server-core/src/auth/bitbucket-oauth.d.ts +11 -0
  16. package/dist/server-core/src/auth/discord-oauth.d.ts +14 -0
  17. package/dist/server-core/src/auth/facebook-oauth.d.ts +14 -0
  18. package/dist/server-core/src/auth/github-oauth.d.ts +15 -0
  19. package/dist/server-core/src/auth/gitlab-oauth.d.ts +13 -0
  20. package/dist/server-core/src/auth/google-oauth.d.ts +5 -11
  21. package/dist/server-core/src/auth/index.d.ts +13 -2
  22. package/dist/server-core/src/auth/interfaces.d.ts +45 -6
  23. package/dist/server-core/src/auth/jwt.d.ts +1 -0
  24. package/dist/server-core/src/auth/linkedin-oauth.d.ts +18 -0
  25. package/dist/server-core/src/auth/microsoft-oauth.d.ts +16 -0
  26. package/dist/server-core/src/auth/middleware.d.ts +36 -11
  27. package/dist/server-core/src/auth/rate-limiter.d.ts +2 -2
  28. package/dist/server-core/src/auth/routes.d.ts +13 -3
  29. package/dist/server-core/src/auth/slack-oauth.d.ts +12 -0
  30. package/dist/server-core/src/auth/spotify-oauth.d.ts +12 -0
  31. package/dist/server-core/src/auth/twitter-oauth.d.ts +18 -0
  32. package/dist/server-core/src/cron/cron-loader.d.ts +17 -0
  33. package/dist/server-core/src/cron/cron-routes.d.ts +14 -0
  34. package/dist/server-core/src/cron/cron-scheduler.d.ts +61 -0
  35. package/dist/server-core/src/cron/cron-store.d.ts +32 -0
  36. package/dist/server-core/src/cron/index.d.ts +6 -0
  37. package/dist/server-core/src/email/index.d.ts +2 -2
  38. package/dist/server-core/src/email/templates.d.ts +9 -0
  39. package/dist/server-core/src/email/types.d.ts +20 -23
  40. package/dist/server-core/src/history/history-routes.d.ts +2 -2
  41. package/dist/server-core/src/index.d.ts +5 -0
  42. package/dist/server-core/src/init.d.ts +112 -2
  43. package/dist/server-core/src/singleton.d.ts +35 -0
  44. package/dist/server-core/src/storage/LocalStorageController.d.ts +9 -9
  45. package/dist/server-core/src/storage/S3StorageController.d.ts +8 -8
  46. package/dist/server-core/src/storage/index.d.ts +18 -11
  47. package/dist/server-core/src/storage/routes.d.ts +3 -3
  48. package/dist/server-core/src/storage/types.d.ts +28 -16
  49. package/dist/server-core/src/utils/dev-port.d.ts +35 -0
  50. package/dist/server-core/src/utils/logger.d.ts +31 -0
  51. package/dist/server-core/src/utils/request-logger.d.ts +19 -0
  52. package/dist/types/src/controllers/auth.d.ts +2 -0
  53. package/dist/types/src/controllers/client.d.ts +119 -7
  54. package/dist/types/src/controllers/collection_registry.d.ts +4 -3
  55. package/dist/types/src/controllers/customization_controller.d.ts +7 -1
  56. package/dist/types/src/controllers/data.d.ts +34 -7
  57. package/dist/types/src/controllers/data_driver.d.ts +20 -28
  58. package/dist/types/src/controllers/database_admin.d.ts +2 -2
  59. package/dist/types/src/controllers/email.d.ts +34 -0
  60. package/dist/types/src/controllers/index.d.ts +1 -0
  61. package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
  62. package/dist/types/src/controllers/navigation.d.ts +5 -5
  63. package/dist/types/src/controllers/registry.d.ts +6 -3
  64. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
  65. package/dist/types/src/controllers/storage.d.ts +24 -26
  66. package/dist/types/src/rebase_context.d.ts +8 -4
  67. package/dist/types/src/types/backend.d.ts +4 -1
  68. package/dist/types/src/types/builders.d.ts +5 -4
  69. package/dist/types/src/types/chips.d.ts +1 -1
  70. package/dist/types/src/types/collections.d.ts +169 -125
  71. package/dist/types/src/types/cron.d.ts +102 -0
  72. package/dist/types/src/types/data_source.d.ts +1 -1
  73. package/dist/types/src/types/entity_actions.d.ts +8 -8
  74. package/dist/types/src/types/entity_callbacks.d.ts +15 -15
  75. package/dist/types/src/types/entity_link_builder.d.ts +1 -1
  76. package/dist/types/src/types/entity_overrides.d.ts +2 -1
  77. package/dist/types/src/types/entity_views.d.ts +8 -8
  78. package/dist/types/src/types/export_import.d.ts +3 -3
  79. package/dist/types/src/types/index.d.ts +1 -0
  80. package/dist/types/src/types/plugins.d.ts +72 -18
  81. package/dist/types/src/types/properties.d.ts +118 -33
  82. package/dist/types/src/types/relations.d.ts +1 -1
  83. package/dist/types/src/types/slots.d.ts +30 -6
  84. package/dist/types/src/types/translations.d.ts +44 -0
  85. package/dist/types/src/types/user_management_delegate.d.ts +1 -0
  86. package/package.json +84 -84
  87. package/scratch.ts +6 -5
  88. package/src/api/ast-schema-editor.ts +11 -11
  89. package/src/api/collections_for_test/callbacks_test_collection.ts +5 -2
  90. package/src/api/errors.ts +38 -14
  91. package/src/api/graphql/graphql-schema-generator.ts +5 -3
  92. package/src/api/openapi-generator.ts +613 -58
  93. package/src/api/rest/api-generator.ts +17 -11
  94. package/src/api/rest/query-parser.ts +1 -1
  95. package/src/api/schema-editor-routes.ts +2 -0
  96. package/src/api/server.ts +8 -5
  97. package/src/api/types.ts +1 -1
  98. package/src/auth/admin-routes.ts +58 -17
  99. package/src/auth/apple-oauth.ts +130 -0
  100. package/src/auth/bitbucket-oauth.ts +82 -0
  101. package/src/auth/discord-oauth.ts +83 -0
  102. package/src/auth/facebook-oauth.ts +72 -0
  103. package/src/auth/github-oauth.ts +110 -0
  104. package/src/auth/gitlab-oauth.ts +70 -0
  105. package/src/auth/google-oauth.ts +34 -46
  106. package/src/auth/index.ts +15 -2
  107. package/src/auth/interfaces.ts +54 -7
  108. package/src/auth/jwt.ts +22 -5
  109. package/src/auth/linkedin-oauth.ts +81 -0
  110. package/src/auth/microsoft-oauth.ts +88 -0
  111. package/src/auth/middleware.ts +194 -45
  112. package/src/auth/password.ts +3 -1
  113. package/src/auth/rate-limiter.ts +4 -4
  114. package/src/auth/routes.ts +197 -139
  115. package/src/auth/slack-oauth.ts +71 -0
  116. package/src/auth/spotify-oauth.ts +67 -0
  117. package/src/auth/twitter-oauth.ts +120 -0
  118. package/src/collections/loader.ts +6 -6
  119. package/src/cron/cron-loader.ts +89 -0
  120. package/src/cron/cron-routes.test.ts +265 -0
  121. package/src/cron/cron-routes.ts +85 -0
  122. package/src/cron/cron-scheduler.test.ts +421 -0
  123. package/src/cron/cron-scheduler.ts +413 -0
  124. package/src/cron/cron-store.ts +163 -0
  125. package/src/cron/index.ts +6 -0
  126. package/src/db/interfaces.ts +2 -2
  127. package/src/email/index.ts +3 -2
  128. package/src/email/smtp-email-service.ts +5 -2
  129. package/src/email/templates.ts +93 -6
  130. package/src/email/types.ts +17 -24
  131. package/src/functions/function-loader.ts +34 -6
  132. package/src/functions/function-routes.ts +2 -2
  133. package/src/history/history-routes.ts +4 -3
  134. package/src/index.ts +10 -0
  135. package/src/init.ts +475 -57
  136. package/src/services/driver-registry.ts +1 -1
  137. package/src/singleton.test.ts +28 -0
  138. package/src/singleton.ts +70 -0
  139. package/src/storage/LocalStorageController.ts +50 -53
  140. package/src/storage/S3StorageController.ts +49 -46
  141. package/src/storage/index.ts +27 -16
  142. package/src/storage/routes.ts +76 -59
  143. package/src/storage/storage-registry.ts +5 -5
  144. package/src/storage/types.ts +44 -32
  145. package/src/types/index.ts +4 -4
  146. package/src/utils/dev-port.ts +176 -0
  147. package/src/utils/logger.ts +143 -0
  148. package/src/utils/logging.ts +5 -2
  149. package/src/utils/request-logger.ts +66 -0
  150. package/test/admin-routes.test.ts +114 -65
  151. package/test/api-generator.test.ts +73 -30
  152. package/test/ast-schema-editor.test.ts +5 -3
  153. package/test/auth-middleware-hono.test.ts +268 -33
  154. package/test/auth-routes.test.ts +325 -146
  155. package/test/driver-registry.test.ts +4 -2
  156. package/test/error-propagation.test.ts +226 -0
  157. package/test/errors.test.ts +12 -7
  158. package/test/jwt-security.test.ts +18 -9
  159. package/test/jwt.test.ts +26 -13
  160. package/test/middleware.test.ts +13 -8
  161. package/test/query-parser.test.ts +13 -8
  162. package/test/rate-limiter.test.ts +7 -7
  163. package/test/safe-compare.test.ts +66 -0
  164. package/test/singleton.test.ts +59 -0
  165. package/test/storage-local.test.ts +36 -43
  166. package/test/storage-registry.test.ts +38 -36
  167. package/test/storage-routes.test.ts +9 -5
  168. package/test/storage-s3.test.ts +51 -48
  169. package/test.ts +6 -0
  170. package/tsconfig.json +1 -1
  171. package/vite.config.ts +7 -5
  172. package/dist/index-BeMqpmfQ.js +0 -239
  173. package/dist/index-BeMqpmfQ.js.map +0 -1
  174. package/dist/index-bl4J3lNb.js +0 -55823
  175. package/dist/index-bl4J3lNb.js.map +0 -1
  176. 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('.ts') || file.endsWith('.js')) &&
21
- !file.includes('.test.') &&
22
- !file.endsWith('.d.ts') &&
23
- file !== 'index.ts' && file !== 'index.js') {
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('url', 'return import(url)');
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
+ }