@clawnet/template-minimal 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.agents/skills/claude-agent-sdk/.claude-plugin/plugin.json +13 -0
  2. package/.agents/skills/claude-agent-sdk/SKILL.md +954 -0
  3. package/.agents/skills/claude-agent-sdk/references/mcp-servers-guide.md +387 -0
  4. package/.agents/skills/claude-agent-sdk/references/permissions-guide.md +429 -0
  5. package/.agents/skills/claude-agent-sdk/references/query-api-reference.md +437 -0
  6. package/.agents/skills/claude-agent-sdk/references/session-management.md +419 -0
  7. package/.agents/skills/claude-agent-sdk/references/subagents-patterns.md +464 -0
  8. package/.agents/skills/claude-agent-sdk/references/top-errors.md +503 -0
  9. package/.agents/skills/claude-agent-sdk/rules/claude-agent-sdk.md +96 -0
  10. package/.agents/skills/claude-agent-sdk/scripts/check-versions.sh +55 -0
  11. package/.agents/skills/claude-agent-sdk/templates/basic-query.ts +55 -0
  12. package/.agents/skills/claude-agent-sdk/templates/custom-mcp-server.ts +161 -0
  13. package/.agents/skills/claude-agent-sdk/templates/error-handling.ts +283 -0
  14. package/.agents/skills/claude-agent-sdk/templates/filesystem-settings.ts +211 -0
  15. package/.agents/skills/claude-agent-sdk/templates/multi-agent-workflow.ts +318 -0
  16. package/.agents/skills/claude-agent-sdk/templates/package.json +30 -0
  17. package/.agents/skills/claude-agent-sdk/templates/permission-control.ts +211 -0
  18. package/.agents/skills/claude-agent-sdk/templates/query-with-tools.ts +54 -0
  19. package/.agents/skills/claude-agent-sdk/templates/session-management.ts +151 -0
  20. package/.agents/skills/claude-agent-sdk/templates/subagents-orchestration.ts +166 -0
  21. package/.agents/skills/claude-agent-sdk/templates/tsconfig.json +22 -0
  22. package/.claude/settings.local.json +70 -0
  23. package/.claude/skills/moltbook-example/SKILL.md +79 -0
  24. package/.claude/skills/post/SKILL.md +130 -0
  25. package/.env.example +4 -0
  26. package/.vercel/README.txt +11 -0
  27. package/.vercel/project.json +1 -0
  28. package/AGENTS.md +114 -0
  29. package/CLAUDE.md +532 -0
  30. package/README.md +44 -0
  31. package/api/index.ts +3 -0
  32. package/biome.json +14 -0
  33. package/clark_avatar.jpeg +0 -0
  34. package/package.json +21 -0
  35. package/scripts/wake.ts +38 -0
  36. package/skills/clawbook/HEARTBEAT.md +142 -0
  37. package/skills/clawbook/SKILL.md +219 -0
  38. package/skills/moltbook-example/SKILL.md +79 -0
  39. package/skills/moltbook-example/bot/index.ts +61 -0
  40. package/src/agent/prompts.ts +98 -0
  41. package/src/agent/runner.ts +526 -0
  42. package/src/agent/tool-definitions.ts +1151 -0
  43. package/src/agent-options.ts +14 -0
  44. package/src/bot-identity.ts +41 -0
  45. package/src/constants.ts +15 -0
  46. package/src/handlers/heartbeat.ts +21 -0
  47. package/src/handlers/openai-compat.ts +95 -0
  48. package/src/handlers/post.ts +21 -0
  49. package/src/identity.ts +83 -0
  50. package/src/index.ts +30 -0
  51. package/src/middleware/cron-auth.ts +53 -0
  52. package/src/middleware/sigma-auth.ts +147 -0
  53. package/src/runs.ts +49 -0
  54. package/tests/agent/prompts.test.ts +172 -0
  55. package/tests/agent/runner.test.ts +353 -0
  56. package/tests/agent/tool-definitions.test.ts +171 -0
  57. package/tests/constants.test.ts +24 -0
  58. package/tests/handlers/openai-compat.test.ts +128 -0
  59. package/tests/handlers.test.ts +133 -0
  60. package/tests/identity.test.ts +66 -0
  61. package/tests/index.test.ts +108 -0
  62. package/tests/middleware/cron-auth.test.ts +99 -0
  63. package/tests/middleware/sigma-auth.test.ts +198 -0
  64. package/tests/runs.test.ts +56 -0
  65. package/tests/skill.test.ts +71 -0
  66. package/tsconfig.json +14 -0
  67. package/vercel.json +9 -0
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ AIP_PREFIX,
4
+ APP_NAME,
5
+ B_PREFIX,
6
+ BAP_PREFIX,
7
+ MAP_PREFIX,
8
+ MOLTBOOK_API_URL,
9
+ } from "../src/constants";
10
+
11
+ describe("constants", () => {
12
+ it("has correct protocol prefixes", () => {
13
+ expect(B_PREFIX).toBe("19HxigV4QyBv3tHpQVcUEQyq1pzZVdoAut");
14
+ expect(MAP_PREFIX).toBe("1PuQa7K62MiKCtssSLKy1kh56WWU7MtUR5");
15
+ expect(AIP_PREFIX).toBe("15PciHG22SNLQJXMoSUaWVi7WSqc7hCfva");
16
+ expect(BAP_PREFIX).toBe("1BAPSuaPnfGnSBM3GLV9yhxUdYe4vGbdMT");
17
+ });
18
+ it("app name is clawbook", () => {
19
+ expect(APP_NAME).toBe("clawbook");
20
+ });
21
+ it("MOLTBOOK_API_URL is correct", () => {
22
+ expect(MOLTBOOK_API_URL).toBe("https://www.moltbook.com/api/v1");
23
+ });
24
+ });
@@ -0,0 +1,128 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ // Mock @vercel/sandbox
5
+ vi.mock("@vercel/sandbox", () => ({
6
+ Sandbox: {
7
+ create: vi.fn().mockResolvedValue({
8
+ runCommand: vi.fn().mockImplementation(({ cmd, args }) => {
9
+ if (cmd === "node" && args?.[0] === "agent.mjs") {
10
+ return {
11
+ exitCode: 0,
12
+ stdout: async () =>
13
+ JSON.stringify({
14
+ success: true,
15
+ summary: "Here is what happened on Clawbook today.",
16
+ actions: [],
17
+ }),
18
+ stderr: async () => "",
19
+ };
20
+ }
21
+ return {
22
+ exitCode: 0,
23
+ stdout: async () => "",
24
+ stderr: async () => "",
25
+ };
26
+ }),
27
+ writeFiles: vi.fn().mockResolvedValue(undefined),
28
+ stop: vi.fn().mockResolvedValue(undefined),
29
+ }),
30
+ },
31
+ }));
32
+
33
+ // Import handler directly and wire to a test app (bypassing auth middleware)
34
+ import { handleChatCompletions } from "../../src/handlers/openai-compat";
35
+
36
+ function createTestApp() {
37
+ const app = new Hono();
38
+ app.post("/v1/chat/completions", handleChatCompletions);
39
+ return app;
40
+ }
41
+
42
+ describe("POST /v1/chat/completions", () => {
43
+ const app = createTestApp();
44
+
45
+ it("returns 400 for missing messages", async () => {
46
+ const res = await app.request("/v1/chat/completions", {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({}),
50
+ });
51
+ expect(res.status).toBe(400);
52
+ const body = await res.json();
53
+ expect(body.error).toContain("messages");
54
+ });
55
+
56
+ it("returns 400 for empty messages array", async () => {
57
+ const res = await app.request("/v1/chat/completions", {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify({ messages: [] }),
61
+ });
62
+ expect(res.status).toBe(400);
63
+ });
64
+
65
+ it("returns 400 when no user message in array", async () => {
66
+ const res = await app.request("/v1/chat/completions", {
67
+ method: "POST",
68
+ headers: { "Content-Type": "application/json" },
69
+ body: JSON.stringify({
70
+ messages: [{ role: "system", content: "You are helpful" }],
71
+ }),
72
+ });
73
+ expect(res.status).toBe(400);
74
+ const body = await res.json();
75
+ expect(body.error).toContain("user message");
76
+ });
77
+
78
+ it("returns non-streaming chat completion format", async () => {
79
+ const res = await app.request("/v1/chat/completions", {
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({
83
+ messages: [{ role: "user", content: "What's on Clawbook?" }],
84
+ }),
85
+ });
86
+ expect(res.status).toBe(200);
87
+ const body = await res.json();
88
+ expect(body.object).toBe("chat.completion");
89
+ expect(body.model).toBe("clarkling");
90
+ expect(body.choices).toHaveLength(1);
91
+ expect(body.choices[0].message.role).toBe("assistant");
92
+ expect(body.choices[0].message.content).toContain("Clawbook");
93
+ expect(body.choices[0].finish_reason).toBe("stop");
94
+ expect(body.usage).toBeDefined();
95
+ });
96
+
97
+ it("extracts last user message from messages array", async () => {
98
+ const res = await app.request("/v1/chat/completions", {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify({
102
+ messages: [
103
+ { role: "system", content: "You are helpful" },
104
+ { role: "user", content: "First question" },
105
+ { role: "assistant", content: "First answer" },
106
+ { role: "user", content: "Second question" },
107
+ ],
108
+ }),
109
+ });
110
+ expect(res.status).toBe(200);
111
+ });
112
+
113
+ it("returns streaming SSE format", async () => {
114
+ const res = await app.request("/v1/chat/completions", {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({
118
+ messages: [{ role: "user", content: "Hello" }],
119
+ stream: true,
120
+ }),
121
+ });
122
+ expect(res.status).toBe(200);
123
+ const text = await res.text();
124
+ expect(text).toContain("data: ");
125
+ expect(text).toContain("[DONE]");
126
+ expect(text).toContain("chat.completion.chunk");
127
+ });
128
+ });
@@ -0,0 +1,133 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock @vercel/sandbox so runAgentTurn doesn't try to create real sandboxes
4
+ const mockRunCommand = vi.fn();
5
+ const mockWriteFiles = vi.fn();
6
+ const mockStop = vi.fn();
7
+ const mockCreate = vi.fn().mockResolvedValue({
8
+ runCommand: mockRunCommand,
9
+ writeFiles: mockWriteFiles,
10
+ stop: mockStop,
11
+ });
12
+
13
+ vi.mock("@vercel/sandbox", () => ({
14
+ Sandbox: { create: mockCreate },
15
+ }));
16
+
17
+ function cmdResult(exitCode: number, stdoutStr: string, stderrStr = "") {
18
+ return {
19
+ exitCode,
20
+ stdout: async () => stdoutStr,
21
+ stderr: async () => stderrStr,
22
+ };
23
+ }
24
+
25
+ describe("handlers", () => {
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ process.env.SIGMA_MEMBER_WIF = "L1test";
29
+ process.env.ANTHROPIC_API_KEY = "sk-test";
30
+
31
+ // Default: script returns success
32
+ mockRunCommand.mockImplementation(({ cmd, args }) => {
33
+ if (cmd === "node" && args?.[0] === "agent.mjs") {
34
+ return cmdResult(
35
+ 0,
36
+ JSON.stringify({
37
+ success: true,
38
+ summary: "Agent completed",
39
+ actions: [],
40
+ }),
41
+ );
42
+ }
43
+ return cmdResult(0, "");
44
+ });
45
+ });
46
+
47
+ describe("handlePost", () => {
48
+ it("runs agent with scheduled_post trigger", async () => {
49
+ mockRunCommand.mockImplementation(({ cmd, args }) => {
50
+ if (cmd === "node" && args?.[0] === "agent.mjs") {
51
+ return cmdResult(
52
+ 0,
53
+ JSON.stringify({
54
+ success: true,
55
+ summary: "Posted something",
56
+ actions: [{ tool: "create_post", input: { content: "test" } }],
57
+ }),
58
+ );
59
+ }
60
+ return cmdResult(0, "");
61
+ });
62
+
63
+ const { handlePost } = await import("../src/handlers/post");
64
+ const result = await handlePost();
65
+
66
+ expect(mockCreate).toHaveBeenCalled();
67
+ expect(result.success).toBe(true);
68
+ expect(result.summary).toBe("Posted something");
69
+ });
70
+
71
+ it("returns error result on failure", async () => {
72
+ mockRunCommand.mockImplementation(({ cmd, args }) => {
73
+ if (cmd === "node" && args?.[0] === "agent.mjs") {
74
+ return cmdResult(1, "", "Sandbox timeout");
75
+ }
76
+ return cmdResult(0, "");
77
+ });
78
+
79
+ const { handlePost } = await import("../src/handlers/post");
80
+ const result = await handlePost();
81
+ expect(result.success).toBe(false);
82
+ });
83
+ });
84
+
85
+ describe("handleHeartbeat", () => {
86
+ it("runs agent with heartbeat trigger", async () => {
87
+ mockRunCommand.mockImplementation(({ cmd, args }) => {
88
+ if (cmd === "node" && args?.[0] === "agent.mjs") {
89
+ return cmdResult(
90
+ 0,
91
+ JSON.stringify({
92
+ success: true,
93
+ summary: "Liked 2 posts",
94
+ actions: [
95
+ { tool: "like_post", input: { targetTxId: "tx1" } },
96
+ { tool: "like_post", input: { targetTxId: "tx2" } },
97
+ ],
98
+ }),
99
+ );
100
+ }
101
+ return cmdResult(0, "");
102
+ });
103
+
104
+ const { handleHeartbeat } = await import("../src/handlers/heartbeat");
105
+ const result = await handleHeartbeat();
106
+
107
+ expect(mockCreate).toHaveBeenCalled();
108
+ expect(result.success).toBe(true);
109
+ });
110
+
111
+ it("catches thrown errors and returns failure result", async () => {
112
+ mockCreate.mockRejectedValueOnce(new Error("sandbox exploded"));
113
+
114
+ const { handleHeartbeat } = await import("../src/handlers/heartbeat");
115
+ const result = await handleHeartbeat();
116
+
117
+ expect(result.success).toBe(false);
118
+ expect(result.error).toBe("sandbox exploded");
119
+ });
120
+ });
121
+
122
+ describe("handlePost error handling", () => {
123
+ it("catches thrown errors and returns failure result", async () => {
124
+ mockCreate.mockRejectedValueOnce(new Error("sandbox exploded"));
125
+
126
+ const { handlePost } = await import("../src/handlers/post");
127
+ const result = await handlePost();
128
+
129
+ expect(result.success).toBe(false);
130
+ expect(result.error).toBe("sandbox exploded");
131
+ });
132
+ });
133
+ });
@@ -0,0 +1,66 @@
1
+ import { PrivateKey } from "@bsv/sdk";
2
+ import { describe, expect, test } from "vitest";
3
+ import { createIdentity, getIdKey, signOpReturn } from "../src/identity";
4
+
5
+ describe("BAP Identity", () => {
6
+ const testWif = PrivateKey.fromRandom().toWif();
7
+
8
+ test("createIdentity returns an identity object", () => {
9
+ const identity = createIdentity(testWif);
10
+ expect(identity).toBeDefined();
11
+ });
12
+
13
+ test("getIdKey returns a string BAP identity key", () => {
14
+ const identity = createIdentity(testWif);
15
+ const idKey = getIdKey(identity);
16
+ expect(typeof idKey).toBe("string");
17
+ expect(idKey.length).toBeGreaterThan(0);
18
+ });
19
+
20
+ test("signOpReturn produces AIP signature data", () => {
21
+ const identity = createIdentity(testWif);
22
+
23
+ // Example OP_RETURN data (hex encoded arrays)
24
+ const opReturnData: number[][] = [
25
+ Buffer.from("1PuQa7K62MiKCtssSLKy1kh56WWU7MtUR5").toJSON().data, // MAP prefix
26
+ Buffer.from("SET").toJSON().data,
27
+ Buffer.from("app").toJSON().data,
28
+ Buffer.from("clawbook").toJSON().data,
29
+ ];
30
+
31
+ const signedData = signOpReturn(identity, opReturnData);
32
+
33
+ expect(Array.isArray(signedData)).toBe(true);
34
+ expect(signedData.length).toBeGreaterThan(opReturnData.length);
35
+
36
+ // AIP signature format: [...original data, "|", AIP_PREFIX, "BITCOIN_ECDSA", address, signature]
37
+ const pipeHex = Buffer.from("|").toString("hex");
38
+ const aipPrefixHex = Buffer.from(
39
+ "15PciHG22SNLQJXMoSUaWVi7WSqc7hCfva",
40
+ ).toString("hex");
41
+
42
+ // Check for pipe separator
43
+ const pipeElementHex = Buffer.from(
44
+ signedData[opReturnData.length],
45
+ ).toString("hex");
46
+ expect(pipeElementHex).toBe(pipeHex);
47
+
48
+ // Check that AIP prefix follows pipe
49
+ const aipElementHex = Buffer.from(
50
+ signedData[opReturnData.length + 1],
51
+ ).toString("hex");
52
+ expect(aipElementHex).toBe(aipPrefixHex);
53
+ });
54
+
55
+ test("identity is deterministic (same WIF = same idKey)", () => {
56
+ const wif = PrivateKey.fromRandom().toWif();
57
+
58
+ const identity1 = createIdentity(wif);
59
+ const identity2 = createIdentity(wif);
60
+
61
+ const idKey1 = getIdKey(identity1);
62
+ const idKey2 = getIdKey(identity2);
63
+
64
+ expect(idKey1).toBe(idKey2);
65
+ });
66
+ });
@@ -0,0 +1,108 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ // Mock @vercel/sandbox so agent turns don't create real sandboxes
5
+ vi.mock("@vercel/sandbox", () => ({
6
+ Sandbox: {
7
+ create: vi.fn().mockResolvedValue({
8
+ runCommand: vi.fn().mockImplementation(({ cmd, args }) => {
9
+ if (cmd === "node" && args?.[0] === "agent.mjs") {
10
+ return {
11
+ exitCode: 0,
12
+ stdout: async () =>
13
+ JSON.stringify({
14
+ success: true,
15
+ summary: "Test",
16
+ actions: [],
17
+ }),
18
+ stderr: async () => "",
19
+ };
20
+ }
21
+ return {
22
+ exitCode: 0,
23
+ stdout: async () => "",
24
+ stderr: async () => "",
25
+ };
26
+ }),
27
+ writeFiles: vi.fn().mockResolvedValue(undefined),
28
+ stop: vi.fn().mockResolvedValue(undefined),
29
+ }),
30
+ },
31
+ }));
32
+
33
+ import app from "../src/index";
34
+
35
+ describe("API", () => {
36
+ it("GET / returns status", async () => {
37
+ const res = await app.request("/");
38
+ expect(res.status).toBe(200);
39
+ const body = await res.json();
40
+ expect(body.name).toBe("clawbook-bot");
41
+ expect(body.status).toBe("ok");
42
+ });
43
+
44
+ it("POST /api/cron/heartbeat returns 200 (no CRON_SECRET = dev mode)", async () => {
45
+ const res = await app.request("/api/cron/heartbeat", { method: "POST" });
46
+ expect(res.status).toBe(200);
47
+ });
48
+
49
+ it("POST /api/cron/post returns 200 (no CRON_SECRET = dev mode)", async () => {
50
+ const res = await app.request("/api/cron/post", { method: "POST" });
51
+ expect(res.status).toBe(200);
52
+ });
53
+
54
+ it("POST /api/agent requires Sigma Auth", async () => {
55
+ const res = await app.request("/api/agent", { method: "POST" });
56
+ expect(res.status).toBe(401);
57
+ });
58
+
59
+ it("POST /api/hooks/wake requires Sigma Auth", async () => {
60
+ const res = await app.request("/api/hooks/wake", {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify({ text: "hello" }),
64
+ });
65
+ expect(res.status).toBe(401);
66
+ });
67
+
68
+ it("POST /api/hooks/agent requires Sigma Auth", async () => {
69
+ const res = await app.request("/api/hooks/agent", {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json" },
72
+ body: JSON.stringify({ message: "hello" }),
73
+ });
74
+ expect(res.status).toBe(401);
75
+ });
76
+
77
+ it("POST /v1/chat/completions requires Sigma Auth", async () => {
78
+ const res = await app.request("/v1/chat/completions", {
79
+ method: "POST",
80
+ headers: { "Content-Type": "application/json" },
81
+ body: JSON.stringify({
82
+ messages: [{ role: "user", content: "hello" }],
83
+ }),
84
+ });
85
+ expect(res.status).toBe(401);
86
+ });
87
+
88
+ it("GET /api/runs/:runId returns 404 for unknown run", async () => {
89
+ const res = await app.request("/api/runs/nonexistent-id");
90
+ expect(res.status).toBe(404);
91
+ });
92
+
93
+ it("global error handler catches unhandled throws and returns 500 JSON", async () => {
94
+ // Use a fresh Hono app to test onError pattern (can't add routes after router is built)
95
+ const testApp = new Hono();
96
+ testApp.onError((_err, c) => {
97
+ return c.json({ error: "Internal server error" }, 500);
98
+ });
99
+ testApp.get("/boom", () => {
100
+ throw new Error("unhandled boom");
101
+ });
102
+
103
+ const res = await testApp.request("/boom");
104
+ expect(res.status).toBe(500);
105
+ const body = await res.json();
106
+ expect(body.error).toBe("Internal server error");
107
+ });
108
+ });
@@ -0,0 +1,99 @@
1
+ import { Hono } from "hono";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { cronAuth } from "../../src/middleware/cron-auth";
4
+
5
+ describe("cronAuth middleware", () => {
6
+ let app: Hono;
7
+ const originalEnv = process.env.CRON_SECRET;
8
+
9
+ beforeEach(() => {
10
+ app = new Hono();
11
+ app.use("/cron/*", cronAuth);
12
+ app.get("/cron/test", (c) => c.json({ success: true }));
13
+ });
14
+
15
+ afterEach(() => {
16
+ // Restore original env
17
+ if (originalEnv !== undefined) {
18
+ process.env.CRON_SECRET = originalEnv;
19
+ } else {
20
+ delete process.env.CRON_SECRET;
21
+ }
22
+ vi.restoreAllMocks();
23
+ });
24
+
25
+ it("allows request with correct secret", async () => {
26
+ process.env.CRON_SECRET = "test-secret-123";
27
+
28
+ const res = await app.request("/cron/test", {
29
+ headers: { Authorization: "Bearer test-secret-123" },
30
+ });
31
+
32
+ expect(res.status).toBe(200);
33
+ const body = await res.json();
34
+ expect(body).toEqual({ success: true });
35
+ });
36
+
37
+ it("rejects request with wrong secret", async () => {
38
+ process.env.CRON_SECRET = "test-secret-123";
39
+
40
+ const res = await app.request("/cron/test", {
41
+ headers: { Authorization: "Bearer wrong-secret" },
42
+ });
43
+
44
+ expect(res.status).toBe(401);
45
+ const body = await res.json();
46
+ expect(body).toHaveProperty("error", "Unauthorized");
47
+ });
48
+
49
+ it("rejects request with missing Authorization header", async () => {
50
+ process.env.CRON_SECRET = "test-secret-123";
51
+
52
+ const res = await app.request("/cron/test");
53
+
54
+ expect(res.status).toBe(401);
55
+ const body = await res.json();
56
+ expect(body).toHaveProperty("error", "Missing Authorization header");
57
+ });
58
+
59
+ it("rejects request with invalid Authorization header format", async () => {
60
+ process.env.CRON_SECRET = "test-secret-123";
61
+
62
+ const res = await app.request("/cron/test", {
63
+ headers: { Authorization: "InvalidFormat" },
64
+ });
65
+
66
+ expect(res.status).toBe(401);
67
+ const body = await res.json();
68
+ expect(body).toHaveProperty("error", "Invalid Authorization header format");
69
+ });
70
+
71
+ it("rejects request with different length secret (timing attack protection)", async () => {
72
+ process.env.CRON_SECRET = "short";
73
+
74
+ const res = await app.request("/cron/test", {
75
+ headers: { Authorization: "Bearer verylongsecret" },
76
+ });
77
+
78
+ expect(res.status).toBe(401);
79
+ const body = await res.json();
80
+ expect(body).toHaveProperty("error", "Unauthorized");
81
+ });
82
+
83
+ it("allows request when CRON_SECRET is not set (dev mode)", async () => {
84
+ delete process.env.CRON_SECRET;
85
+
86
+ const consoleWarnSpy = vi
87
+ .spyOn(console, "warn")
88
+ .mockImplementation(() => {});
89
+
90
+ const res = await app.request("/cron/test");
91
+
92
+ expect(res.status).toBe(200);
93
+ const body = await res.json();
94
+ expect(body).toEqual({ success: true });
95
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
96
+ "[cronAuth] CRON_SECRET not set - allowing request (dev mode)",
97
+ );
98
+ });
99
+ });