mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 04:29:24 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ec8176f97 | ||
|
|
d544046fb3 | ||
|
|
06a5f9bbe1 | ||
|
|
bed977ba04 | ||
|
|
b6b3013e7e |
No files matched your search
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
|
||||
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
|
||||
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
|
||||
- **Website maintainer and cron secrets:** the manual cron trigger is rate limited, admin sign-in and the cron secret require a 32 to 512 character secret with at least 8 distinct characters that is not a repeated pattern, and changing the maintainer token signs out existing admin sessions.
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
|
||||
|
||||
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
|
||||
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
|
||||
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
|
||||
- **Website maintainer and cron secrets:** the manual cron trigger is rate limited, admin sign-in and the cron secret require a 32 to 512 character secret with at least 8 distinct characters that is not a repeated pattern, and changing the maintainer token signs out existing admin sessions.
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
|
||||
|
||||
Generated
+9
-10
@@ -161,15 +161,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-app-server-protocol"
|
||||
version = "0.10.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-pet-tui"
|
||||
version = "0.1.0"
|
||||
@@ -179,12 +170,20 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-app-server-protocol"
|
||||
version = "0.10.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codewhale-protocol"
|
||||
version = "0.10.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"codewhale-app-server-protocol",
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
|
||||
+9
-2
@@ -9,14 +9,21 @@ GITHUB_TOKEN=
|
||||
GITHUB_REPO=codewhale-hq/CodeWhale
|
||||
|
||||
# Optional — required to manually invoke /api/cron
|
||||
# (cloudflare cron triggers don't need this; they set cf-cron).
|
||||
# (cloudflare cron triggers don't need this; they set cf-cron). When set it must
|
||||
# be 32 to 512 characters with at least 8 distinct, and a shorter value repeated
|
||||
# is refused; otherwise the trigger stays disabled.
|
||||
# Generate one with: openssl rand -base64 33
|
||||
CRON_SECRET=
|
||||
|
||||
# Optional — defaults to deepseek-flash.
|
||||
DEEPSEEK_MODEL=deepseek-flash
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
|
||||
# Admin panel auth. Set to a random secret; enter it in the /admin login form.
|
||||
# Admin panel auth. Set to a random secret of 32 to 512 characters with at
|
||||
# least 8 distinct characters, and a shorter value repeated is refused (anything
|
||||
# else leaves admin sign-in disabled); enter it in the /admin login form.
|
||||
# Changing it signs out open admin sessions.
|
||||
# Generate one with: openssl rand -base64 33
|
||||
MAINTAINER_TOKEN=
|
||||
|
||||
# GitHub PAT for posting comments via /admin. Needs issues:write scope.
|
||||
|
||||
+5
-3
@@ -87,8 +87,8 @@ form. The form posts to `/api/admin/login`; the token never goes in the URL.
|
||||
|---|---|---|
|
||||
| `DEEPSEEK_API_KEY` | Yes | DeepSeek API key for the community agent |
|
||||
| `GITHUB_TOKEN` | Optional | Fine-grained PAT for GitHub API (raises rate limit) |
|
||||
| `CRON_SECRET` | Optional | Shared secret for manual cron invocation |
|
||||
| `MAINTAINER_TOKEN` | Optional | Auth token for /admin panel |
|
||||
| `CRON_SECRET` | Optional | Shared secret for manual cron invocation (32 to 512 characters, at least 8 distinct, and a shorter value repeated is refused; otherwise the trigger stays disabled); attempts are rate limited (5 per minute per location) |
|
||||
| `MAINTAINER_TOKEN` | Optional | Auth token for /admin panel; 32 to 512 characters with at least 8 distinct, and a shorter value repeated is refused (otherwise sign-in stays disabled); changing it signs out open sessions |
|
||||
| `MAINTAINER_GITHUB_PAT` | Optional | GitHub PAT with `issues:write` scope for posting comments |
|
||||
|
||||
## Initial deployment
|
||||
@@ -103,7 +103,9 @@ One-time setup before the first `npm run deploy`:
|
||||
Copy the returned `id` values into the matching `CURATED_KV` and
|
||||
`NEXT_INC_CACHE_KV` bindings in `cloudflare.config.ts`.
|
||||
|
||||
2. **Set secrets:**
|
||||
2. **Set secrets:** generate `MAINTAINER_TOKEN` and `CRON_SECRET` with
|
||||
`openssl rand -base64 33` (any random value of 32 to 512 characters with at
|
||||
least 8 distinct and not a shorter value repeated works; `openssl rand -hex 32` is fine).
|
||||
```bash
|
||||
npx wrangler secret put DEEPSEEK_API_KEY
|
||||
npx wrangler secret put MAINTAINER_TOKEN
|
||||
|
||||
+2
-2
@@ -20,10 +20,10 @@ Env (mirrors `.env.example`):
|
||||
| `DEEPSEEK_API_KEY` | DeepSeek platform key (`sk-...`) | only for the `/api/cron` tasks (summarization + community agent) |
|
||||
| `GITHUB_TOKEN` | Fine-grained PAT, public-repo read scope | optional (raises rate limit 60 → 5000 req/h) |
|
||||
| `GITHUB_REPO` | Defaults to `codewhale-hq/CodeWhale` | optional |
|
||||
| `CRON_SECRET` | Shared secret for manual `/api/cron` invocation | optional (Cloudflare cron triggers don't need it) |
|
||||
| `CRON_SECRET` | Shared secret for manual `/api/cron` invocation (32-512 characters, 8+ distinct; a shorter value repeated is refused)| optional (Cloudflare cron triggers don't need it) |
|
||||
| `DEEPSEEK_MODEL` | Defaults to `deepseek-flash` | optional |
|
||||
| `DEEPSEEK_BASE_URL` | Defaults to `https://api.deepseek.com` | optional |
|
||||
| `MAINTAINER_TOKEN` | Admin panel auth; enter it in the `/admin` login form | only for `/admin` |
|
||||
| `MAINTAINER_TOKEN` | Admin panel auth (32-512 characters, 8+ distinct; a shorter value repeated is refused; changing it signs out sessions); enter it in the `/admin` login form | only for `/admin` |
|
||||
| `MAINTAINER_GITHUB_PAT` | PAT with `issues:write`, for posting comments via `/admin` | only for `/admin` posting |
|
||||
| `NEXT_PUBLIC_GITEE_ENABLED` | Set to `1` once the Gitee mirror exists; blank hides Gitee links | optional |
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ConnectionBanner } from "@/components/connection-banner";
|
||||
import { EmptyState, ErrorState } from "@/components/surface-state";
|
||||
import { WhalePose } from "@/components/whale-pose";
|
||||
import { getAgentEnv, listDrafts, validateSession, type AgentDraft } from "@/lib/community-agent";
|
||||
import { MAINTAINER_TOKEN_UNAVAILABLE, maintainerTokenUsable } from "@/lib/maintainer-token";
|
||||
import { AdminClient } from "./admin-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -77,7 +78,8 @@ export default async function AdminPage({
|
||||
|
||||
const env = await getAgentEnv();
|
||||
|
||||
if (!env.MAINTAINER_TOKEN) {
|
||||
if (!maintainerTokenUsable(env.MAINTAINER_TOKEN)) {
|
||||
if (env.MAINTAINER_TOKEN) console.warn(MAINTAINER_TOKEN_UNAVAILABLE);
|
||||
return (
|
||||
<div className="route-state">
|
||||
<ConnectionBanner locale={locale} />
|
||||
@@ -98,7 +100,7 @@ export default async function AdminPage({
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const sid = cookieStore.get("mt_sid")?.value;
|
||||
const authed = await validateSession(env.CURATED_KV, sid);
|
||||
const authed = await validateSession(env.CURATED_KV, sid, env.MAINTAINER_TOKEN);
|
||||
|
||||
if (!authed) {
|
||||
return <LoginForm locale={locale} error={err === "1"} />;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAgentEnv, safeEqual, createSession } from "@/lib/community-agent";
|
||||
import { MAINTAINER_TOKEN_UNAVAILABLE, MAX_SECRET_CHARS, maintainerTokenUsable } from "@/lib/maintainer-token";
|
||||
import { FormBodyError, readBoundedUrlEncodedForm } from "@/lib/bounded-form";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ALLOWED_LOCALES = new Set(["en", "zh"]);
|
||||
const MAX_LOGIN_BODY_BYTES = 4_096;
|
||||
const MAX_TOKEN_CHARS = 512;
|
||||
|
||||
function pickLocale(value: string | null | undefined): string {
|
||||
if (!value) return "en";
|
||||
@@ -18,8 +18,8 @@ export async function POST(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const localeFromQuery = pickLocale(url.searchParams.get("locale"));
|
||||
|
||||
if (!env.MAINTAINER_TOKEN) {
|
||||
return new NextResponse("Not configured", {
|
||||
if (!maintainerTokenUsable(env.MAINTAINER_TOKEN)) {
|
||||
return new NextResponse(MAINTAINER_TOKEN_UNAVAILABLE, {
|
||||
status: 503,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
@@ -58,7 +58,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
const submitted = form.get("token") ?? "";
|
||||
const locale = pickLocale(form.get("locale") ?? localeFromQuery);
|
||||
if (submitted.length > MAX_TOKEN_CHARS) {
|
||||
if (submitted.length > MAX_SECRET_CHARS) {
|
||||
return new NextResponse("Token too long", {
|
||||
status: 413,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
@@ -73,7 +73,7 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const sid = await createSession(env.CURATED_KV);
|
||||
const sid = await createSession(env.CURATED_KV, env.MAINTAINER_TOKEN);
|
||||
if (!sid) {
|
||||
return new NextResponse("Session storage unavailable", {
|
||||
status: 503,
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const authEnv = await getAgentEnv();
|
||||
const sid = request.headers.get("cookie")?.split(";").map(c => c.trim()).find(c => c.startsWith("mt_sid="))?.slice(7);
|
||||
if (!authEnv.MAINTAINER_TOKEN || !await validateSession(authEnv.CURATED_KV, sid)) throw new MerchError(401, "unauthorized", "Maintainer sign-in is required.");
|
||||
if (!await validateSession(authEnv.CURATED_KV, sid, authEnv.MAINTAINER_TOKEN)) throw new MerchError(401, "unauthorized", "Maintainer sign-in is required.");
|
||||
const env = await merchEnv(), config = parseConfig(env.MERCH_CONFIG_JSON);
|
||||
if (!config || request.headers.get("origin") !== new URL(config.siteOrigin).origin || !request.headers.get("content-type")?.startsWith("application/json")) throw new MerchError(403, "request_blocked", "Use the approved store origin.");
|
||||
const raw = await readBoundedBody(request, 8192);
|
||||
|
||||
@@ -39,7 +39,7 @@ async function checkAuth(req: Request, env: CommunityAgentEnv): Promise<{ ok: bo
|
||||
}
|
||||
}
|
||||
|
||||
if (!sid || !(await validateSession(env.CURATED_KV, sid))) {
|
||||
if (!sid || !(await validateSession(env.CURATED_KV, sid, env.MAINTAINER_TOKEN))) {
|
||||
return { ok: false, status: 401, error: "unauthorized" };
|
||||
}
|
||||
return { ok: true };
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AgentEnv } from "@/lib/community-agent-tasks";
|
||||
import { runFactsDrift } from "@/lib/facts-drift";
|
||||
import { runLinkCheck, runSemanticDrift } from "@/lib/content-watch";
|
||||
import { safeEqual } from "@/lib/community-agent";
|
||||
import { CRON_SECRET_UNAVAILABLE, secretStrongEnough } from "@/lib/maintainer-token";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -30,9 +31,33 @@ export async function GET(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!secretStrongEnough(env.CRON_SECRET)) {
|
||||
console.warn(CRON_SECRET_UNAVAILABLE);
|
||||
return NextResponse.json(
|
||||
{ error: CRON_SECRET_UNAVAILABLE },
|
||||
{ status: 503, headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!env.ADMIN_LOGIN_LIMITER) throw new Error("Missing cron limiter");
|
||||
const { success } = await env.ADMIN_LOGIN_LIMITER.limit({ key: "codewhale-web:cron-secret" });
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: "too many attempts" },
|
||||
{ status: 429, headers: { "Cache-Control": "no-store", "Retry-After": "60" } }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "manual trigger temporarily unavailable" },
|
||||
{ status: 503, headers: { "Cache-Control": "no-store", "Retry-After": "60" } }
|
||||
);
|
||||
}
|
||||
|
||||
const auth = req.headers.get("x-cron-secret") ?? "";
|
||||
if (!(await safeEqual(auth, env.CRON_SECRET))) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401, headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("@/lib/community-agent", async (importOriginal) => {
|
||||
import { POST } from "@/app/api/admin/login/route";
|
||||
import { createSession, safeEqual, validateSession } from "@/lib/community-agent";
|
||||
|
||||
const FIXTURE_TOKEN = "local-admin-integration-fixture";
|
||||
const FIXTURE_TOKEN = "local-admin-integration-fixture-0123456789";
|
||||
const sessions = new Map<string, string>();
|
||||
const kv = {
|
||||
put: vi.fn(async (key: string, value: string) => { sessions.set(key, value); }),
|
||||
@@ -57,9 +57,9 @@ describe("admin login with real credential and session helpers", () => {
|
||||
const sid = response.cookies.get("mt_sid")?.value;
|
||||
expect(sid).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(sessions.size).toBe(1);
|
||||
expect(await validateSession(sessionKv, sid)).toBe(true);
|
||||
expect(await validateSession(sessionKv, sid, FIXTURE_TOKEN)).toBe(true);
|
||||
expect(safeEqual).toHaveBeenCalledExactlyOnceWith(FIXTURE_TOKEN, FIXTURE_TOKEN);
|
||||
expect(createSession).toHaveBeenCalledExactlyOnceWith(sessionKv);
|
||||
expect(createSession).toHaveBeenCalledExactlyOnceWith(sessionKv, FIXTURE_TOKEN);
|
||||
const cookie = response.headers.get("Set-Cookie")?.toLowerCase();
|
||||
expect(cookie).toContain("httponly");
|
||||
expect(cookie).toContain("secure");
|
||||
@@ -69,7 +69,13 @@ describe("admin login with real credential and session helpers", () => {
|
||||
expect.any(String), expect.any(String), { expirationTtl: 86400 },
|
||||
]);
|
||||
sessions.clear();
|
||||
expect(await validateSession(sessionKv, sid)).toBe(false);
|
||||
expect(await validateSession(sessionKv, sid, FIXTURE_TOKEN)).toBe(false);
|
||||
});
|
||||
|
||||
it("stops honouring a session after the maintainer token is rotated", async () => {
|
||||
const sid = (await POST(request())).cookies.get("mt_sid")?.value;
|
||||
expect(await validateSession(sessionKv, sid, FIXTURE_TOKEN)).toBe(true);
|
||||
expect(await validateSession(sessionKv, sid, "rotated-integration-fixture-9876543210")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a wrong credential without creating a session", async () => {
|
||||
|
||||
@@ -5,6 +5,9 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock("@/lib/community-agent", () => mocks);
|
||||
import { POST } from "@/app/api/admin/login/route";
|
||||
|
||||
const STRONG_TOKEN = "local-fixture-token-0123456789-abcdefghij";
|
||||
const LONE_SURROGATES = Array.from({ length: 16 }, (_, index) => String.fromCharCode(0xd800 + index)).join("").repeat(2);
|
||||
|
||||
function request(token = "wrong", ip = "127.0.0.1") {
|
||||
return new Request("https://codewhale.net/api/admin/login?locale=en", {
|
||||
method: "POST",
|
||||
@@ -14,7 +17,7 @@ function request(token = "wrong", ip = "127.0.0.1") {
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.getAgentEnv.mockResolvedValue({ MAINTAINER_TOKEN: "configured", ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
|
||||
mocks.getAgentEnv.mockResolvedValue({ MAINTAINER_TOKEN: STRONG_TOKEN, ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
|
||||
mocks.limit.mockResolvedValue({ success: true });
|
||||
mocks.safeEqual.mockResolvedValue(false);
|
||||
mocks.createSession.mockResolvedValue("session-id");
|
||||
@@ -35,7 +38,7 @@ describe("admin login attempt boundary", () => {
|
||||
});
|
||||
it("does not parse or compare credentials after refusal", async () => {
|
||||
mocks.limit.mockResolvedValue({ success: false });
|
||||
const req = request("configured");
|
||||
const req = request(STRONG_TOKEN);
|
||||
const read = vi.spyOn(req, "text");
|
||||
expect((await POST(req)).status).toBe(429);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
@@ -43,16 +46,47 @@ describe("admin login attempt boundary", () => {
|
||||
expect(mocks.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
it("fails closed if the binding is missing or unavailable", async () => {
|
||||
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: "configured" });
|
||||
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: STRONG_TOKEN });
|
||||
expect((await POST(request())).status).toBe(503);
|
||||
mocks.limit.mockRejectedValue(new Error("binding unavailable"));
|
||||
expect((await POST(request())).status).toBe(503);
|
||||
expect(mocks.safeEqual).not.toHaveBeenCalled();
|
||||
expect(mocks.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refuses to run with a configured token below the minimum strength", async () => {
|
||||
const weakTokens = [
|
||||
"x", "configured", "a".repeat(31), "a".repeat(32), " ".repeat(40), "password".repeat(4),
|
||||
"Password1".repeat(4), "0123456".repeat(5), "\u{1F433}".repeat(16), "L".repeat(600), LONE_SURROGATES,
|
||||
];
|
||||
for (const weak of weakTokens) {
|
||||
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: weak, ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
|
||||
mocks.safeEqual.mockResolvedValue(true);
|
||||
const response = await POST(request(weak));
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(await response.text()).toContain("at least 32 characters");
|
||||
}
|
||||
expect(mocks.limit).not.toHaveBeenCalled();
|
||||
expect(mocks.safeEqual).not.toHaveBeenCalled();
|
||||
expect(mocks.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
it("accepts a token of exactly the minimum length", async () => {
|
||||
const exact = "abcdefghijklmnopqrstuvwxyz012345";
|
||||
expect(exact).toHaveLength(32);
|
||||
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: exact, ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
|
||||
mocks.safeEqual.mockResolvedValue(true);
|
||||
expect((await POST(request(exact))).status).toBe(303);
|
||||
});
|
||||
it("accepts a 32-character hex secret with fewer than 16 distinct characters", async () => {
|
||||
const hex = "3f9a3f9a1c7e1c7e3f9a1c7e5b2d5b2d";
|
||||
expect(new Set(hex).size).toBeLessThan(16);
|
||||
mocks.getAgentEnv.mockResolvedValueOnce({ MAINTAINER_TOKEN: hex, ADMIN_LOGIN_LIMITER: { limit: mocks.limit }, CURATED_KV: {} });
|
||||
mocks.safeEqual.mockResolvedValue(true);
|
||||
expect((await POST(request(hex))).status).toBe(303);
|
||||
});
|
||||
it("retains the authenticated session and redirect after an allowed attempt", async () => {
|
||||
mocks.safeEqual.mockResolvedValue(true);
|
||||
const response = await POST(request("configured"));
|
||||
const response = await POST(request(STRONG_TOKEN));
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get("Location")).toBe("https://codewhale.net/en/admin");
|
||||
const cookie = response.headers.get("Set-Cookie");
|
||||
|
||||
@@ -144,7 +144,7 @@ function stubAdminEnv(kv: FakeKv, lock?: FakeDraftClaimLock) {
|
||||
mocks.getAgentEnv.mockResolvedValue({
|
||||
CURATED_KV: kv,
|
||||
...(lock ? { DRAFT_CLAIM_LOCK: lock } : {}),
|
||||
MAINTAINER_TOKEN: "configured",
|
||||
MAINTAINER_TOKEN: "local-fixture-token-0123456789-abcdefghij",
|
||||
MAINTAINER_GITHUB_PAT: "ghp_test",
|
||||
GITHUB_REPO: "codewhale-hq/CodeWhale",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { logUsage, validateSession } from "./community-agent";
|
||||
import { createSession, logUsage, validateSession } from "./community-agent";
|
||||
|
||||
describe("validateSession", () => {
|
||||
let mockKV: {
|
||||
@@ -20,65 +20,88 @@ describe("validateSession", () => {
|
||||
|
||||
const VALID_SID = "a".repeat(40);
|
||||
const SESSION_PREFIX = "session:admin:";
|
||||
const TOKEN = "local-fixture-token-0123456789-abcdefghij";
|
||||
const ROTATED = "rotated-fixture-token-9876543210-zyxwvutsrq";
|
||||
|
||||
it("should return false if kv is undefined", async () => {
|
||||
const result = await validateSession(undefined, VALID_SID);
|
||||
const result = await validateSession(undefined, VALID_SID, TOKEN);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if sid is undefined or null", async () => {
|
||||
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
|
||||
expect(await validateSession(kv, undefined)).toBe(false);
|
||||
expect(await validateSession(kv, null)).toBe(false);
|
||||
expect(await validateSession(kv, undefined, TOKEN)).toBe(false);
|
||||
expect(await validateSession(kv, null, TOKEN)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if sid is empty", async () => {
|
||||
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
|
||||
expect(await validateSession(kv, "")).toBe(false);
|
||||
expect(await validateSession(kv, "", TOKEN)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if sid length is less than 40", async () => {
|
||||
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
|
||||
expect(await validateSession(kv, "a".repeat(39))).toBe(false);
|
||||
expect(await validateSession(kv, "a".repeat(39), TOKEN)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if sid length is greater than 64", async () => {
|
||||
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
|
||||
expect(await validateSession(kv, "a".repeat(65))).toBe(false);
|
||||
expect(await validateSession(kv, "a".repeat(65), TOKEN)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if sid contains invalid characters", async () => {
|
||||
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
|
||||
// Length 40 but contains invalid char '!'
|
||||
expect(await validateSession(kv, "a".repeat(39) + "!")).toBe(false);
|
||||
expect(await validateSession(kv, "a".repeat(39) + "!", TOKEN)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if session is not found in KV", async () => {
|
||||
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
|
||||
mockKV.get.mockResolvedValue(null);
|
||||
|
||||
const result = await validateSession(kv, VALID_SID);
|
||||
const result = await validateSession(kv, VALID_SID, TOKEN);
|
||||
expect(result).toBe(false);
|
||||
expect(mockKV.get).toHaveBeenCalledWith(SESSION_PREFIX + VALID_SID);
|
||||
});
|
||||
|
||||
it("should return true if session is found in KV", async () => {
|
||||
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
|
||||
mockKV.get.mockResolvedValue(JSON.stringify({ createdAt: Date.now() }));
|
||||
|
||||
const result = await validateSession(kv, VALID_SID);
|
||||
expect(result).toBe(true);
|
||||
expect(mockKV.get).toHaveBeenCalledWith(SESSION_PREFIX + VALID_SID);
|
||||
it("should return true for a session minted under the configured token", async () => {
|
||||
const store = new Map<string, string>();
|
||||
const kv = {
|
||||
get: async (key: string) => store.get(key) ?? null,
|
||||
put: async (key: string, value: string) => { store.set(key, value); },
|
||||
} as unknown as Parameters<typeof validateSession>[0];
|
||||
const sid = await createSession(kv, TOKEN);
|
||||
expect(sid).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(await validateSession(kv, sid, TOKEN)).toBe(true);
|
||||
});
|
||||
|
||||
it("should work with a 64 character sid", async () => {
|
||||
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
|
||||
mockKV.get.mockResolvedValue("{}");
|
||||
it("should refuse the same session once the configured token changes", async () => {
|
||||
const store = new Map<string, string>();
|
||||
const kv = {
|
||||
get: async (key: string) => store.get(key) ?? null,
|
||||
put: async (key: string, value: string) => { store.set(key, value); },
|
||||
} as unknown as Parameters<typeof validateSession>[0];
|
||||
const sid = await createSession(kv, TOKEN);
|
||||
expect(await validateSession(kv, sid, ROTATED)).toBe(false);
|
||||
expect(await validateSession(kv, sid, "short")).toBe(false);
|
||||
expect(await validateSession(kv, sid, undefined)).toBe(false);
|
||||
expect(await validateSession(kv, sid, TOKEN)).toBe(true);
|
||||
});
|
||||
|
||||
const sid = "b".repeat(64);
|
||||
const result = await validateSession(kv, sid);
|
||||
expect(result).toBe(true);
|
||||
expect(mockKV.get).toHaveBeenCalledWith(SESSION_PREFIX + sid);
|
||||
it("should refuse a stored record with no token fingerprint or unparseable content", async () => {
|
||||
const kv = mockKV as unknown as Parameters<typeof validateSession>[0];
|
||||
for (const raw of [JSON.stringify({ createdAt: Date.now() }), "{}", "not json", JSON.stringify({ tokenFingerprint: 7 })]) {
|
||||
mockKV.get.mockResolvedValue(raw);
|
||||
expect(await validateSession(kv, VALID_SID, TOKEN)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not mint a session under a weak token", async () => {
|
||||
const kv = mockKV as unknown as Parameters<typeof createSession>[0];
|
||||
for (const weak of [undefined, "", "short", "a".repeat(32)]) {
|
||||
expect(await createSession(kv, weak)).toBeNull();
|
||||
}
|
||||
expect(mockKV.put).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import type { DraftClaimLockNamespace, DraftClaimLockStub, DraftLockAction } from "./draft-claim-lock";
|
||||
|
||||
import { maintainerTokenUsable } from "./maintainer-token";
|
||||
import { requestChat, type ChatMessage, type DeepSeekEnv } from "./deepseek";
|
||||
export type { DeepSeekEnv } from "./deepseek";
|
||||
|
||||
@@ -722,6 +723,7 @@ export async function deleteDraft(kv: KVNamespace | undefined, key: string): Pro
|
||||
|
||||
const SESSION_PREFIX = "session:admin:";
|
||||
const SESSION_TTL_SEC = 60 * 60 * 24; // 24h
|
||||
const SESSION_TOKEN_DOMAIN = "codewhale-web:admin-session:v1:";
|
||||
|
||||
function toBase64Url(bytes: Uint8Array): string {
|
||||
let s = "";
|
||||
@@ -738,21 +740,39 @@ export async function safeEqual(a: string, b: string): Promise<boolean> {
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export async function createSession(kv: KVNamespace | undefined): Promise<string | null> {
|
||||
if (!kv) return null;
|
||||
async function tokenFingerprint(token: string): Promise<string> {
|
||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(SESSION_TOKEN_DOMAIN + token)));
|
||||
let hex = "";
|
||||
for (const byte of digest) hex += byte.toString(16).padStart(2, "0");
|
||||
return hex;
|
||||
}
|
||||
|
||||
export async function createSession(kv: KVNamespace | undefined, token: string | undefined): Promise<string | null> {
|
||||
if (!kv || !maintainerTokenUsable(token)) return null;
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
const sid = toBase64Url(bytes);
|
||||
const value = JSON.stringify({ createdAt: Date.now() });
|
||||
const value = JSON.stringify({ createdAt: Date.now(), tokenFingerprint: await tokenFingerprint(token) });
|
||||
await kv.put(SESSION_PREFIX + sid, value, { expirationTtl: SESSION_TTL_SEC });
|
||||
return sid;
|
||||
}
|
||||
|
||||
export async function validateSession(kv: KVNamespace | undefined, sid: string | undefined | null): Promise<boolean> {
|
||||
if (!kv || !sid) return false;
|
||||
export async function validateSession(
|
||||
kv: KVNamespace | undefined,
|
||||
sid: string | undefined | null,
|
||||
token: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!kv || !sid || !maintainerTokenUsable(token)) return false;
|
||||
if (!/^[A-Za-z0-9_-]{40,64}$/.test(sid)) return false;
|
||||
const raw = await kv.get(SESSION_PREFIX + sid);
|
||||
return raw !== null;
|
||||
if (raw === null) return false;
|
||||
let stored: unknown;
|
||||
try {
|
||||
stored = (JSON.parse(raw) as { tokenFingerprint?: unknown }).tokenFingerprint;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return typeof stored === "string" && (await safeEqual(stored, await tokenFingerprint(token)));
|
||||
}
|
||||
|
||||
export async function deleteSession(kv: KVNamespace | undefined, sid: string | undefined | null): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getEnv: vi.fn(), safeEqual: vi.fn(), limit: vi.fn() }));
|
||||
vi.mock("@/lib/kv", () => ({ getEnv: mocks.getEnv }));
|
||||
vi.mock("@/lib/community-agent", () => ({ safeEqual: mocks.safeEqual }));
|
||||
vi.mock("@/lib/facts-drift", () => ({ runFactsDrift: vi.fn() }));
|
||||
vi.mock("@/lib/content-watch", () => ({ runLinkCheck: vi.fn(), runSemanticDrift: vi.fn() }));
|
||||
import { GET } from "@/app/api/cron/route";
|
||||
|
||||
function request(secret = "wrong", ip = "127.0.0.1") {
|
||||
return new Request("https://codewhale.net/api/cron?task=nope", {
|
||||
headers: { "x-cron-secret": secret, "CF-Connecting-IP": ip },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.getEnv.mockResolvedValue({ CRON_SECRET: "configured-cron-secret-0123456789-abcdef", ADMIN_LOGIN_LIMITER: { limit: mocks.limit } });
|
||||
mocks.limit.mockResolvedValue({ success: true });
|
||||
mocks.safeEqual.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe("manual cron trigger attempt boundary", () => {
|
||||
it("bounds repeated guesses independently of the secret and forged IP", async () => {
|
||||
let attempts = 0;
|
||||
mocks.limit.mockImplementation(async () => ({ success: ++attempts <= 5 }));
|
||||
for (let index = 0; index < 7; index++) {
|
||||
const response = await GET(request(`guess-${index}`, `192.0.2.${index}`));
|
||||
expect(response.status).toBe(index < 5 ? 401 : 429);
|
||||
if (index >= 5) expect(response.headers.get("Retry-After")).toBe("60");
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
}
|
||||
expect(mocks.safeEqual).toHaveBeenCalledTimes(5);
|
||||
expect(mocks.limit.mock.calls.map(([options]) => options.key)).toEqual(Array(7).fill("codewhale-web:cron-secret"));
|
||||
});
|
||||
it("does not compare the secret after refusal", async () => {
|
||||
mocks.limit.mockResolvedValue({ success: false });
|
||||
expect((await GET(request("configured-cron-secret-0123456789-abcdef"))).status).toBe(429);
|
||||
expect(mocks.safeEqual).not.toHaveBeenCalled();
|
||||
});
|
||||
it("fails closed if the binding is missing or unavailable", async () => {
|
||||
mocks.getEnv.mockResolvedValueOnce({ CRON_SECRET: "configured-cron-secret-0123456789-abcdef" });
|
||||
expect((await GET(request())).status).toBe(503);
|
||||
mocks.limit.mockRejectedValue(new Error("binding unavailable"));
|
||||
expect((await GET(request())).status).toBe(503);
|
||||
expect(mocks.safeEqual).not.toHaveBeenCalled();
|
||||
});
|
||||
it("stays disabled without a configured secret and never consults the limiter", async () => {
|
||||
mocks.getEnv.mockResolvedValue({ ADMIN_LOGIN_LIMITER: { limit: mocks.limit } });
|
||||
expect((await GET(request())).status).toBe(503);
|
||||
expect(mocks.limit).not.toHaveBeenCalled();
|
||||
});
|
||||
it("stays disabled for a secret below the minimum strength and never consults the limiter or compares", async () => {
|
||||
const loneSurrogates = Array.from({ length: 16 }, (_, index) => String.fromCharCode(0xd800 + index)).join("").repeat(2);
|
||||
const weak = ["x", "a".repeat(32), " ".repeat(40), "password".repeat(4), "Password1".repeat(4), "\u{1F433}".repeat(16), "0123456".repeat(5), "L".repeat(600), loneSurrogates];
|
||||
for (const secret of weak) {
|
||||
mocks.getEnv.mockResolvedValueOnce({ CRON_SECRET: secret, ADMIN_LOGIN_LIMITER: { limit: mocks.limit } });
|
||||
const response = await GET(request());
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(JSON.stringify(await response.json())).toContain("at least 32 characters");
|
||||
}
|
||||
expect(mocks.limit).not.toHaveBeenCalled();
|
||||
expect(mocks.safeEqual).not.toHaveBeenCalled();
|
||||
});
|
||||
it("accepts a 32-character hex secret with fewer than 16 distinct characters", async () => {
|
||||
const hex = "3f9a3f9a1c7e1c7e3f9a1c7e5b2d5b2d";
|
||||
mocks.getEnv.mockResolvedValueOnce({ CRON_SECRET: hex, ADMIN_LOGIN_LIMITER: { limit: mocks.limit } });
|
||||
mocks.safeEqual.mockResolvedValue(true);
|
||||
expect((await GET(request(hex))).status).toBe(400);
|
||||
});
|
||||
it("still reaches task validation after an allowed, correct attempt", async () => {
|
||||
mocks.safeEqual.mockResolvedValue(true);
|
||||
expect((await GET(request("configured-cron-secret-0123456789-abcdef"))).status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ export async function readBoundedKvJson(kv: KVStreamNamespace | undefined, key:
|
||||
interface CloudflareEnv extends MerchEnv {
|
||||
DRAFT_CLAIM_LOCK?: DraftClaimLockNamespace;
|
||||
CURATED_KV?: KVNamespace & KVStreamNamespace;
|
||||
ADMIN_LOGIN_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> };
|
||||
MERCH_INTEREST_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> };
|
||||
MERCH_INTEREST_SITE_ORIGIN?: string;
|
||||
DEEPSEEK_API_KEY?: string;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export const MIN_SECRET_CHARS = 32;
|
||||
export const MAX_SECRET_CHARS = 512;
|
||||
export const MIN_SECRET_DISTINCT_CHARS = 8;
|
||||
|
||||
const LONE_SURROGATE = /\p{Cs}/u;
|
||||
|
||||
export const MAINTAINER_TOKEN_UNAVAILABLE =
|
||||
`Maintainer sign-in is unavailable: MAINTAINER_TOKEN must be set to a secret of at least ${MIN_SECRET_CHARS} characters ` +
|
||||
`(at most ${MAX_SECRET_CHARS}, with at least ${MIN_SECRET_DISTINCT_CHARS} distinct characters; a shorter value repeated is refused).`;
|
||||
|
||||
export const CRON_SECRET_UNAVAILABLE =
|
||||
`manual trigger disabled: CRON_SECRET must be a secret of at least ${MIN_SECRET_CHARS} characters ` +
|
||||
`(at most ${MAX_SECRET_CHARS}, with at least ${MIN_SECRET_DISTINCT_CHARS} distinct characters; a shorter value repeated is refused)`;
|
||||
|
||||
export function secretStrongEnough(value: string | undefined): value is string {
|
||||
if (typeof value !== "string" || value.length > MAX_SECRET_CHARS || LONE_SURROGATE.test(value)) return false;
|
||||
const chars = [...value];
|
||||
if (chars.length < MIN_SECRET_CHARS || new Set(chars).size < MIN_SECRET_DISTINCT_CHARS) return false;
|
||||
for (let period = 1; period <= value.length / 2; period++) {
|
||||
if (value.slice(period) === value.slice(0, value.length - period)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const maintainerTokenUsable = secretStrongEnough;
|
||||
@@ -155,8 +155,8 @@ describe("durable merch interest registration", () => {
|
||||
|
||||
describe("private merch interest list and deletion", () => {
|
||||
async function signedIn(): Promise<{ auth: CommunityAgentEnv; headers: Record<string, string> }> {
|
||||
const auth = { CURATED_KV: env.CURATED_KV, MAINTAINER_TOKEN: "local-maintainer-fixture" };
|
||||
const sid = await createSession(auth.CURATED_KV);
|
||||
const auth = { CURATED_KV: env.CURATED_KV, MAINTAINER_TOKEN: "local-fixture-token-0123456789-abcdefghij" };
|
||||
const sid = await createSession(auth.CURATED_KV, auth.MAINTAINER_TOKEN);
|
||||
return { auth, headers: { Cookie: "mt_sid=" + sid } };
|
||||
}
|
||||
it("requires a real stored maintainer session for reading or deleting contacts", async () => {
|
||||
@@ -165,6 +165,12 @@ describe("private merch interest list and deletion", () => {
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["short", "rotated-fixture-token-9876543210-zyxwvutsrq"])("refuses an existing session once the configured maintainer token changes to %s", async (replacement) => {
|
||||
const { auth, headers } = await signedIn();
|
||||
const response = await handleAdminMerchInterest(new Request("https://codewhale.net/api/admin/merch/interest", { headers }), env, { ...auth, MAINTAINER_TOKEN: replacement });
|
||||
expect(response.status).toBe(401);
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
it("paginates only the private prefix and deletes an opaque id with same-origin CSRF", async () => {
|
||||
await handleMerchInterest(request(), env);
|
||||
await handleMerchInterest(request({ ...input(), email: "[email protected]" }), env);
|
||||
|
||||
@@ -138,7 +138,7 @@ export async function handleMerchInterest(request: Request, env?: MerchInterestE
|
||||
}
|
||||
async function requireAdmin(request: Request, auth: CommunityAgentEnv) {
|
||||
const sid = request.headers.get("cookie")?.split(";").map(cookie => cookie.trim()).find(cookie => cookie.startsWith("mt_sid="))?.slice(7);
|
||||
if (!auth.MAINTAINER_TOKEN || !await validateSession(auth.CURATED_KV, sid)) throw new InterestError(401, "unauthorized", "Maintainer sign-in is required.");
|
||||
if (!await validateSession(auth.CURATED_KV, sid, auth.MAINTAINER_TOKEN)) throw new InterestError(401, "unauthorized", "Maintainer sign-in is required.");
|
||||
}
|
||||
export async function handleAdminMerchInterest(request: Request, env?: MerchInterestEnv, authEnv?: CommunityAgentEnv): Promise<Response> {
|
||||
try {
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("public security boundaries", () => {
|
||||
kv.values.set("agent:state:latest", JSON.stringify({ state: "running" }));
|
||||
securityMocks.getAgentEnv.mockResolvedValue({
|
||||
CURATED_KV: kv,
|
||||
MAINTAINER_TOKEN: "configured",
|
||||
MAINTAINER_TOKEN: "local-fixture-token-0123456789-abcdefghij",
|
||||
});
|
||||
|
||||
const response = await adminPost(new Request("https://codewhale.net/api/admin/post", {
|
||||
@@ -121,7 +121,7 @@ describe("public security boundaries", () => {
|
||||
|
||||
securityMocks.getAgentEnv.mockResolvedValue({
|
||||
CURATED_KV: kv,
|
||||
MAINTAINER_TOKEN: "configured",
|
||||
MAINTAINER_TOKEN: "local-fixture-token-0123456789-abcdefghij",
|
||||
MAINTAINER_GITHUB_PAT: "ghp_test",
|
||||
GITHUB_REPO: "codewhale-hq/CodeWhale",
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user