Add spam protection, moderation and repo verification

#4
.env.example CHANGED
@@ -4,3 +4,13 @@ BIGQUERY_DATASET=
4
 
5
  # Public canonical site URL, used for metadata and sitemap generation.
6
  NEXT_PUBLIC_SITE_URL=http://localhost:3000
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  # Public canonical site URL, used for metadata and sitemap generation.
6
  NEXT_PUBLIC_SITE_URL=http://localhost:3000
7
+
8
+ # Secret used to sign the voter cookie for ratings. Required in production.
9
+ # Generate one with: openssl rand -base64 32
10
+ VOTER_SECRET=
11
+
12
+ # Bearer token for /api/admin/tools (approve or reject submissions). Leave empty to disable the admin API.
13
+ ADMIN_TOKEN=
14
+
15
+ # Optional GitHub token (no scopes needed). Raises the API limit used to verify submitted repositories.
16
+ GITHUB_TOKEN=
.gitignore CHANGED
@@ -83,6 +83,7 @@ web_modules/
83
  # Next.js build output
84
  .next
85
  out
 
86
 
87
  # Nuxt.js build / generate output
88
  .nuxt
 
83
  # Next.js build output
84
  .next
85
  out
86
+ next-env.d.ts
87
 
88
  # Nuxt.js build / generate output
89
  .nuxt
README.md CHANGED
@@ -47,7 +47,30 @@ gcloud run deploy chainkit \
47
  --region us-central1 \
48
  --allow-unauthenticated \
49
  --service-account chainkit-runtime@YOUR_PROJECT_ID.iam.gserviceaccount.com \
50
- --set-env-vars BIGQUERY_PROJECT_ID=YOUR_PROJECT_ID,BIGQUERY_DATASET=YOUR_DATASET
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  ```
52
 
53
  After deployment, set `NEXT_PUBLIC_SITE_URL` to the Cloud Run URL or your
@@ -65,8 +88,10 @@ runtime service account.
65
 
66
  ## Important production considerations
67
 
68
- - Submissions are immediately public in this starter. Add moderation or an approval workflow before opening submissions to an untrusted audience.
69
- - Ratings use a random browser-local identifier, not an authenticated user identity. It supports repeat edits from the same browser but is not abuse-resistant; add sign-in and rate limiting before treating ratings as verified.
 
 
70
  - The repository URL is validated and stored as a link. Chainkit does not clone, fetch, or execute submitted code.
71
  - BigQuery is optimized for analytics rather than high-volume transactional writes. For substantial rating or submission traffic, consider a transactional store and retain BigQuery for analytics.
72
 
 
47
  --region us-central1 \
48
  --allow-unauthenticated \
49
  --service-account chainkit-runtime@YOUR_PROJECT_ID.iam.gserviceaccount.com \
50
+ --set-env-vars BIGQUERY_PROJECT_ID=YOUR_PROJECT_ID,BIGQUERY_DATASET=YOUR_DATASET \
51
+ --set-secrets VOTER_SECRET=chainkit-voter-secret:latest,ADMIN_TOKEN=chainkit-admin-token:latest,GITHUB_TOKEN=chainkit-github-token:latest
52
+ ```
53
+
54
+ Create the three secrets in Secret Manager first and grant the runtime service
55
+ account `roles/secretmanager.secretAccessor`. If you are upgrading an existing
56
+ deployment, run the migration at the bottom of `infra/bigquery_schema.sql` before
57
+ deploying.
58
+
59
+ ### Upgrading an existing BigQuery table
60
+
61
+ Run the three statements at the bottom of `infra/bigquery_schema.sql` once, either
62
+ in the BigQuery console or with `bq query --use_legacy_sql=false`. They add the
63
+ `status` and `repo_key` columns and mark existing rows as `approved`. Use an account
64
+ with `roles/bigquery.dataEditor` on the dataset and `roles/bigquery.jobUser` on the project.
65
+
66
+ ### Moderation
67
+
68
+ New submissions are stored as `pending` and are not listed until approved:
69
+
70
+ ```sh
71
+ curl -H "Authorization: Bearer $ADMIN_TOKEN" "$URL/api/admin/tools?status=pending"
72
+ curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
73
+ -d '{"action":"approve"}' "$URL/api/admin/tools/TOOL_ID"
74
  ```
75
 
76
  After deployment, set `NEXT_PUBLIC_SITE_URL` to the Cloud Run URL or your
 
88
 
89
  ## Important production considerations
90
 
91
+ - Submissions are held as `pending` until approved through `/api/admin/tools`. The GitHub API is used to confirm the repository exists and is public; a repository that cannot be verified because GitHub is unavailable stays pending. Whether a project is actually about supply chains is a moderator decision.
92
+ - Duplicate repositories are rejected (case-insensitive, ignoring `.git` and a trailing slash). BigQuery cannot enforce uniqueness, so the check runs inside the insert; two exactly simultaneous submissions could still both succeed.
93
+ - `/api/tools` (3 per hour) and `/api/ratings` (30 per hour) are rate limited per client IP in application memory. On Cloud Run this is per instance and resets on cold start, so it is best-effort. Put Cloud Armor in front of the service for a hard limit.
94
+ - Ratings use a server-signed cookie (`VOTER_SECRET`), so voter IDs cannot be made up. Clearing cookies still gives a new identity; the IP rate limit is the second layer. Add sign-in before treating ratings as verified.
95
  - The repository URL is validated and stored as a link. Chainkit does not clone, fetch, or execute submitted code.
96
  - BigQuery is optimized for analytics rather than high-volume transactional writes. For substantial rating or submission traffic, consider a transactional store and retain BigQuery for analytics.
97
 
bun.lock ADDED
The diff for this file is too large to render. See raw diff
 
infra/bigquery_schema.sql CHANGED
@@ -5,8 +5,12 @@ CREATE TABLE IF NOT EXISTS `YOUR_PROJECT_ID.YOUR_DATASET.tools` (
5
  summary STRING NOT NULL,
6
  category STRING NOT NULL,
7
  repo_url STRING NOT NULL,
 
 
8
  website_url STRING,
9
  submitted_by STRING NOT NULL,
 
 
10
  created_at TIMESTAMP NOT NULL
11
  )
12
  PARTITION BY DATE(created_at)
@@ -21,3 +25,16 @@ CREATE TABLE IF NOT EXISTS `YOUR_PROJECT_ID.YOUR_DATASET.tool_ratings` (
21
  )
22
  PARTITION BY DATE(created_at)
23
  CLUSTER BY tool_id, voter_id;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  summary STRING NOT NULL,
6
  category STRING NOT NULL,
7
  repo_url STRING NOT NULL,
8
+ -- Lowercase owner/repo, used to reject duplicate submissions (not enforced by BigQuery).
9
+ repo_key STRING,
10
  website_url STRING,
11
  submitted_by STRING NOT NULL,
12
+ -- pending | approved | rejected. New submissions are pending until a moderator approves them.
13
+ status STRING,
14
  created_at TIMESTAMP NOT NULL
15
  )
16
  PARTITION BY DATE(created_at)
 
25
  )
26
  PARTITION BY DATE(created_at)
27
  CLUSTER BY tool_id, voter_id;
28
+
29
+ -- ---------------------------------------------------------------------------
30
+ -- Migration for an existing deployment (run once, BEFORE deploying this version).
31
+ -- BigQuery cannot add NOT NULL columns, so both are nullable; the app treats a
32
+ -- NULL status as 'approved' until the backfill has run.
33
+ -- ---------------------------------------------------------------------------
34
+ -- ALTER TABLE `YOUR_PROJECT_ID.YOUR_DATASET.tools` ADD COLUMN IF NOT EXISTS repo_key STRING;
35
+ -- ALTER TABLE `YOUR_PROJECT_ID.YOUR_DATASET.tools` ADD COLUMN IF NOT EXISTS status STRING;
36
+ -- UPDATE `YOUR_PROJECT_ID.YOUR_DATASET.tools`
37
+ -- SET
38
+ -- status = COALESCE(status, 'approved'),
39
+ -- repo_key = LOWER(REGEXP_EXTRACT(repo_url, r'^https://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$'))
40
+ -- WHERE TRUE;
next-env.d.ts DELETED
@@ -1,7 +0,0 @@
1
- /// <reference types="next" />
2
- /// <reference types="next/image-types/global" />
3
- import "./.next/types/routes.d.ts";
4
- import "./.next/types/root-params.d.ts";
5
-
6
- // NOTE: This file should not be edited
7
- // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
 
 
 
 
 
 
 
 
package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -8,7 +8,8 @@
8
  "prestart": "node scripts/prepare-standalone.mjs",
9
  "start": "node .next/standalone/server.js",
10
  "lint": "eslint",
11
- "typecheck": "tsc --noEmit"
 
12
  },
13
  "dependencies": {
14
  "@google-cloud/bigquery": "^9.6.0",
@@ -26,6 +27,7 @@
26
  "eslint": "^9",
27
  "eslint-config-next": "16.4.5",
28
  "tailwindcss": "^4",
29
- "typescript": "^5"
 
30
  }
31
  }
 
8
  "prestart": "node scripts/prepare-standalone.mjs",
9
  "start": "node .next/standalone/server.js",
10
  "lint": "eslint",
11
+ "typecheck": "next typegen && tsc --noEmit",
12
+ "test": "vitest run"
13
  },
14
  "dependencies": {
15
  "@google-cloud/bigquery": "^9.6.0",
 
27
  "eslint": "^9",
28
  "eslint-config-next": "16.4.5",
29
  "tailwindcss": "^4",
30
+ "typescript": "^5",
31
+ "vitest": "^3.2.7"
32
  }
33
  }
src/app/api/admin/tools/[id]/route.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { adminDenied, checkAdmin } from "@/lib/admin-auth";
4
+ import { setToolStatus } from "@/lib/tools";
5
+
6
+ const bodySchema = z.object({ action: z.enum(["approve", "reject"]) });
7
+
8
+ export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
9
+ const auth = checkAdmin(request);
10
+ if (auth !== "ok") return adminDenied(auth);
11
+
12
+ const parsed = bodySchema.safeParse(await request.json().catch(() => undefined));
13
+ if (!parsed.success) {
14
+ return NextResponse.json({ error: "action must be 'approve' or 'reject'." }, { status: 400 });
15
+ }
16
+
17
+ const { id } = await context.params;
18
+ const found = await setToolStatus(id, parsed.data.action === "approve" ? "approved" : "rejected");
19
+ if (!found) return NextResponse.json({ error: "Tool not found." }, { status: 404 });
20
+ return NextResponse.json({ id, status: parsed.data.action === "approve" ? "approved" : "rejected" });
21
+ }
src/app/api/admin/tools/route.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { adminDenied, checkAdmin } from "@/lib/admin-auth";
3
+ import { toolStatuses } from "@/lib/tool-types";
4
+ import type { ToolStatus } from "@/lib/tool-types";
5
+ import { listToolsByStatus } from "@/lib/tools";
6
+
7
+ export async function GET(request: Request) {
8
+ const auth = checkAdmin(request);
9
+ if (auth !== "ok") return adminDenied(auth);
10
+
11
+ const status = new URL(request.url).searchParams.get("status") ?? "pending";
12
+ if (!toolStatuses.includes(status as ToolStatus)) {
13
+ return NextResponse.json({ error: "Unknown status." }, { status: 400 });
14
+ }
15
+ const tools = await listToolsByStatus(status as ToolStatus);
16
+ return NextResponse.json({ tools });
17
+ }
src/app/api/ratings/route.ts CHANGED
@@ -1,14 +1,27 @@
1
  import { NextResponse } from "next/server";
 
2
  import { z } from "zod";
 
3
  import { rateTool } from "@/lib/tools";
 
 
 
 
 
 
4
 
 
 
 
5
  const ratingSchema = z.object({
6
  toolId: z.string().min(1).max(80),
7
- voterId: z.string().uuid(),
8
  rating: z.number().int().min(1).max(5),
9
  });
10
 
11
  export async function POST(request: Request) {
 
 
 
12
  let body: unknown;
13
  try {
14
  body = await request.json();
@@ -22,11 +35,21 @@ export async function POST(request: Request) {
22
  }
23
 
24
  try {
25
- const tool = await rateTool(
26
- parsed.data.toolId,
27
- parsed.data.voterId,
28
- parsed.data.rating,
29
- );
 
 
 
 
 
 
 
 
 
 
30
  if (!tool) {
31
  return NextResponse.json({ error: "Tool not found." }, { status: 404 });
32
  }
 
1
  import { NextResponse } from "next/server";
2
+ import { cookies } from "next/headers";
3
  import { z } from "zod";
4
+ import { checkRateLimit, getClientIp, rateLimitResponse } from "@/lib/rate-limit";
5
  import { rateTool } from "@/lib/tools";
6
+ import {
7
+ VOTER_COOKIE,
8
+ VOTER_COOKIE_MAX_AGE,
9
+ createVoterToken,
10
+ verifyVoterToken,
11
+ } from "@/lib/voter";
12
 
13
+ const RATINGS_PER_HOUR = 30;
14
+
15
+ // The voter identity comes from a signed cookie, never from the request body.
16
  const ratingSchema = z.object({
17
  toolId: z.string().min(1).max(80),
 
18
  rating: z.number().int().min(1).max(5),
19
  });
20
 
21
  export async function POST(request: Request) {
22
+ const limit = checkRateLimit(`ratings:${getClientIp(request)}`, RATINGS_PER_HOUR, 60 * 60 * 1000);
23
+ if (!limit.allowed) return rateLimitResponse(limit);
24
+
25
  let body: unknown;
26
  try {
27
  body = await request.json();
 
35
  }
36
 
37
  try {
38
+ const cookieStore = await cookies();
39
+ let voterId = verifyVoterToken(cookieStore.get(VOTER_COOKIE)?.value);
40
+ if (!voterId) {
41
+ const issued = createVoterToken();
42
+ voterId = issued.voterId;
43
+ cookieStore.set(VOTER_COOKIE, issued.token, {
44
+ httpOnly: true,
45
+ sameSite: "lax",
46
+ secure: process.env.NODE_ENV === "production",
47
+ path: "/",
48
+ maxAge: VOTER_COOKIE_MAX_AGE,
49
+ });
50
+ }
51
+
52
+ const tool = await rateTool(parsed.data.toolId, voterId, parsed.data.rating);
53
  if (!tool) {
54
  return NextResponse.json({ error: "Tool not found." }, { status: 404 });
55
  }
src/app/api/tools/route.ts CHANGED
@@ -1,27 +1,16 @@
1
  import { NextResponse } from "next/server";
2
  import { z } from "zod";
3
  import { categories } from "@/lib/tool-types";
4
- import { submitTool } from "@/lib/tools";
 
 
 
 
5
 
6
  const githubRepoUrl = z
7
  .string()
8
  .url()
9
- .refine((value) => {
10
- try {
11
- const url = new URL(value);
12
- return (
13
- url.protocol === "https:" &&
14
- url.hostname === "github.com" &&
15
- !url.username &&
16
- !url.password &&
17
- !url.search &&
18
- !url.hash &&
19
- /^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?\/?$/.test(url.pathname)
20
- );
21
- } catch {
22
- return false;
23
- }
24
- }, "Enter a public GitHub repository URL.");
25
 
26
  const optionalWebsiteUrl = z
27
  .union([z.literal(""), z.string().url()])
@@ -41,6 +30,9 @@ const submissionSchema = z.object({
41
  });
42
 
43
  export async function POST(request: Request) {
 
 
 
44
  let body: unknown;
45
  try {
46
  body = await request.json();
@@ -56,10 +48,40 @@ export async function POST(request: Request) {
56
  );
57
  }
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  try {
60
- const tool = await submitTool(parsed.data);
61
- return NextResponse.json({ tool }, { status: 201 });
 
 
 
62
  } catch (error) {
 
 
 
 
 
 
 
 
 
63
  console.error("Tool submission failed", error);
64
  return NextResponse.json(
65
  { error: "The tool could not be saved. Please try again later." },
 
1
  import { NextResponse } from "next/server";
2
  import { z } from "zod";
3
  import { categories } from "@/lib/tool-types";
4
+ import { parseGithubRepoUrl, verifyRepo } from "@/lib/github";
5
+ import { checkRateLimit, getClientIp, rateLimitResponse } from "@/lib/rate-limit";
6
+ import { DuplicateRepoError, submitTool } from "@/lib/tools";
7
+
8
+ const SUBMISSIONS_PER_HOUR = 3;
9
 
10
  const githubRepoUrl = z
11
  .string()
12
  .url()
13
+ .refine((value) => parseGithubRepoUrl(value) !== undefined, "Enter a public GitHub repository URL.");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  const optionalWebsiteUrl = z
16
  .union([z.literal(""), z.string().url()])
 
30
  });
31
 
32
  export async function POST(request: Request) {
33
+ const limit = checkRateLimit(`tools:${getClientIp(request)}`, SUBMISSIONS_PER_HOUR, 60 * 60 * 1000);
34
+ if (!limit.allowed) return rateLimitResponse(limit);
35
+
36
  let body: unknown;
37
  try {
38
  body = await request.json();
 
48
  );
49
  }
50
 
51
+ const repo = parseGithubRepoUrl(parsed.data.repoUrl);
52
+ if (!repo) {
53
+ return NextResponse.json({ error: "Enter a public GitHub repository URL." }, { status: 400 });
54
+ }
55
+
56
+ // A failed lookup (rate limit, timeout) must not block the submission: it stays
57
+ // pending and a moderator checks it. Only a definite "not found" is rejected.
58
+ const verification = await verifyRepo(repo);
59
+ if (verification.status === "not_found") {
60
+ return NextResponse.json(
61
+ {
62
+ error: "That GitHub repository could not be found. Make sure it exists and is public.",
63
+ fields: { repoUrl: ["That GitHub repository could not be found. Make sure it exists and is public."] },
64
+ },
65
+ { status: 422 },
66
+ );
67
+ }
68
+
69
  try {
70
+ await submitTool(parsed.data);
71
+ return NextResponse.json(
72
+ { status: "pending", message: "Thanks! Your submission is awaiting review." },
73
+ { status: 201 },
74
+ );
75
  } catch (error) {
76
+ if (error instanceof DuplicateRepoError) {
77
+ return NextResponse.json(
78
+ {
79
+ error: error.message,
80
+ fields: { repoUrl: [error.message] },
81
+ },
82
+ { status: 409 },
83
+ );
84
+ }
85
  console.error("Tool submission failed", error);
86
  return NextResponse.json(
87
  { error: "The tool could not be saved. Please try again later." },
src/app/directory.tsx CHANGED
@@ -65,7 +65,7 @@ const emptyForm: FormFields = {
65
  };
66
 
67
  export function Directory({ initialTools }: Props) {
68
- const [tools, setTools] = useState(initialTools);
69
  const [search, setSearch] = useState("");
70
  const searchRef = useRef<HTMLInputElement>(null);
71
  const [category, setCategory] = useState("All tools");
@@ -126,7 +126,6 @@ export function Directory({ initialTools }: Props) {
126
  body: JSON.stringify(form),
127
  });
128
  const data = (await response.json()) as {
129
- tool?: SupplyChainTool;
130
  error?: string;
131
  fields?: Record<string, string[] | undefined>;
132
  };
@@ -134,13 +133,7 @@ export function Directory({ initialTools }: Props) {
134
  const detail = Object.values(data.fields ?? {}).flat().find(Boolean);
135
  throw new Error(detail || data.error || "Your submission could not be saved.");
136
  }
137
- const newTool = data.tool;
138
- if (newTool) {
139
- setTools((current) => [newTool, ...current]);
140
- setSearch("");
141
- setCategory("All tools");
142
- setSort("Recently added");
143
- }
144
  setSubmitted(true);
145
  setForm(emptyForm);
146
  } catch (error) {
@@ -354,9 +347,9 @@ export function Directory({ initialTools }: Props) {
354
  {submitted ? (
355
  <div className="success-panel">
356
  <div className="success-icon"><Check size={23} /></div>
357
- <div className="section-kicker">YOU’RE IN THE INDEX</div>
358
  <h2 id="modal-heading">Thanks for sharing.</h2>
359
- <p>Your project has been added to the community directory.</p>
360
  <button className="button button-primary" onClick={() => setShowForm(false)}>Back to exploring</button>
361
  </div>
362
  ) : (
 
65
  };
66
 
67
  export function Directory({ initialTools }: Props) {
68
+ const [tools] = useState(initialTools);
69
  const [search, setSearch] = useState("");
70
  const searchRef = useRef<HTMLInputElement>(null);
71
  const [category, setCategory] = useState("All tools");
 
126
  body: JSON.stringify(form),
127
  });
128
  const data = (await response.json()) as {
 
129
  error?: string;
130
  fields?: Record<string, string[] | undefined>;
131
  };
 
133
  const detail = Object.values(data.fields ?? {}).flat().find(Boolean);
134
  throw new Error(detail || data.error || "Your submission could not be saved.");
135
  }
136
+ // New submissions are held for review, so they are not added to the list here.
 
 
 
 
 
 
137
  setSubmitted(true);
138
  setForm(emptyForm);
139
  } catch (error) {
 
347
  {submitted ? (
348
  <div className="success-panel">
349
  <div className="success-icon"><Check size={23} /></div>
350
+ <div className="section-kicker">SUBMISSION RECEIVED</div>
351
  <h2 id="modal-heading">Thanks for sharing.</h2>
352
+ <p>Your project is awaiting review and will appear in the directory once it is approved.</p>
353
  <button className="button button-primary" onClick={() => setShowForm(false)}>Back to exploring</button>
354
  </div>
355
  ) : (
src/app/tool-rating.tsx CHANGED
@@ -10,16 +10,6 @@ type Props = {
10
  size?: "small" | "large";
11
  };
12
 
13
- function getVoterId() {
14
- const storageKey = "chainkit-voter-id";
15
- let voterId = window.localStorage.getItem(storageKey);
16
- if (!voterId) {
17
- voterId = window.crypto.randomUUID();
18
- window.localStorage.setItem(storageKey, voterId);
19
- }
20
- return voterId;
21
- }
22
-
23
  export function ToolRating({
24
  toolId,
25
  initialRating,
@@ -39,7 +29,7 @@ export function ToolRating({
39
  const response = await fetch("/api/ratings", {
40
  method: "POST",
41
  headers: { "Content-Type": "application/json" },
42
- body: JSON.stringify({ toolId, rating: value, voterId: getVoterId() }),
43
  });
44
  const data = (await response.json()) as {
45
  averageRating?: number;
 
10
  size?: "small" | "large";
11
  };
12
 
 
 
 
 
 
 
 
 
 
 
13
  export function ToolRating({
14
  toolId,
15
  initialRating,
 
29
  const response = await fetch("/api/ratings", {
30
  method: "POST",
31
  headers: { "Content-Type": "application/json" },
32
+ body: JSON.stringify({ toolId, rating: value }),
33
  });
34
  const data = (await response.json()) as {
35
  averageRating?: number;
src/lib/admin-auth.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * Checks `Authorization: Bearer $ADMIN_TOKEN`. Returns "disabled" when no token is
5
+ * configured so the admin routes can answer 404 and stay invisible.
6
+ */
7
+ export function checkAdmin(request: Request): "ok" | "denied" | "disabled" {
8
+ const expected = process.env.ADMIN_TOKEN;
9
+ if (!expected) return "disabled";
10
+ const header = request.headers.get("authorization") ?? "";
11
+ const provided = header.startsWith("Bearer ") ? header.slice(7) : "";
12
+ // Hash both sides so the comparison has equal length and does not leak it.
13
+ const a = createHash("sha256").update(provided).digest();
14
+ const b = createHash("sha256").update(expected).digest();
15
+ return timingSafeEqual(a, b) ? "ok" : "denied";
16
+ }
17
+
18
+ export function adminDenied(result: "denied" | "disabled") {
19
+ return result === "disabled"
20
+ ? Response.json({ error: "Not found." }, { status: 404 })
21
+ : Response.json({ error: "Unauthorized." }, { status: 401 });
22
+ }
src/lib/github.test.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { parseGithubRepoUrl, verifyRepo } from "@/lib/github";
3
+
4
+ describe("parseGithubRepoUrl", () => {
5
+ it("normalizes case, .git and trailing slash", () => {
6
+ const a = parseGithubRepoUrl("https://github.com/Foo/Bar.git");
7
+ const b = parseGithubRepoUrl("https://github.com/foo/bar/");
8
+ expect(a?.repoKey).toBe("foo/bar");
9
+ expect(b?.repoKey).toBe("foo/bar");
10
+ expect(a?.canonicalUrl).toBe("https://github.com/Foo/Bar");
11
+ });
12
+
13
+ it.each([
14
+ "http://github.com/a/b",
15
+ "https://gitlab.com/a/b",
16
+ "https://github.com/a",
17
+ "https://github.com/a/b/tree/main",
18
+ "https://github.com/a/b?x=1",
19
+ "https://user@github.com/a/b",
20
+ "https://github.com/../b",
21
+ ])("rejects %s", (value) => {
22
+ expect(parseGithubRepoUrl(value)).toBeUndefined();
23
+ });
24
+ });
25
+
26
+ describe("verifyRepo", () => {
27
+ const ref = { owner: "a", repo: "b" };
28
+ const reply = (status: number, body: unknown = {}) =>
29
+ vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status }));
30
+
31
+ it("accepts a public repo", async () => {
32
+ expect(await verifyRepo(ref, reply(200, { private: false }))).toEqual({ status: "ok" });
33
+ });
34
+ it("rejects missing and private repos", async () => {
35
+ expect(await verifyRepo(ref, reply(404))).toEqual({ status: "not_found" });
36
+ expect(await verifyRepo(ref, reply(200, { private: true }))).toEqual({ status: "not_found" });
37
+ });
38
+ it("treats rate limits, 5xx and network errors as unavailable", async () => {
39
+ expect(await verifyRepo(ref, reply(403))).toEqual({ status: "unavailable" });
40
+ expect(await verifyRepo(ref, reply(502))).toEqual({ status: "unavailable" });
41
+ expect(await verifyRepo(ref, vi.fn().mockRejectedValue(new Error("timeout")))).toEqual({
42
+ status: "unavailable",
43
+ });
44
+ });
45
+ });
src/lib/github.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const REPO_PATH = /^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/;
2
+
3
+ export type GithubRepoRef = {
4
+ owner: string;
5
+ repo: string;
6
+ /** Lowercase `owner/repo`, used to detect duplicate submissions. */
7
+ repoKey: string;
8
+ /** Canonical URL stored in the database. */
9
+ canonicalUrl: string;
10
+ };
11
+
12
+ /** Returns undefined unless the value is a plain https://github.com/owner/repo URL. */
13
+ export function parseGithubRepoUrl(value: string): GithubRepoRef | undefined {
14
+ try {
15
+ const url = new URL(value);
16
+ if (
17
+ url.protocol !== "https:" ||
18
+ url.hostname !== "github.com" ||
19
+ url.username ||
20
+ url.password ||
21
+ url.search ||
22
+ url.hash
23
+ ) {
24
+ return undefined;
25
+ }
26
+ const match = REPO_PATH.exec(url.pathname);
27
+ if (!match) return undefined;
28
+ const [, owner, repo] = match;
29
+ if (repo === "." || repo === ".." || owner === "." || owner === "..") return undefined;
30
+ return {
31
+ owner,
32
+ repo,
33
+ repoKey: `${owner}/${repo}`.toLowerCase(),
34
+ canonicalUrl: `https://github.com/${owner}/${repo}`,
35
+ };
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+
41
+ export type RepoVerification =
42
+ | { status: "ok" }
43
+ | { status: "not_found" }
44
+ | { status: "unavailable" };
45
+
46
+ /**
47
+ * Confirms that a public repository exists. `unavailable` means GitHub could not
48
+ * answer (rate limit, timeout, 5xx), so the caller should not reject the submission.
49
+ */
50
+ export async function verifyRepo(
51
+ ref: Pick<GithubRepoRef, "owner" | "repo">,
52
+ fetchImpl: typeof fetch = fetch,
53
+ ): Promise<RepoVerification> {
54
+ const headers: Record<string, string> = {
55
+ Accept: "application/vnd.github+json",
56
+ "User-Agent": "chainkit-directory",
57
+ "X-GitHub-Api-Version": "2022-11-28",
58
+ };
59
+ if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
60
+
61
+ try {
62
+ const response = await fetchImpl(
63
+ `https://api.github.com/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}`,
64
+ { headers, signal: AbortSignal.timeout(5000), cache: "no-store" },
65
+ );
66
+ // Private repos return 404 to unauthenticated callers, which is what we want.
67
+ if (response.status === 404 || response.status === 451) return { status: "not_found" };
68
+ if (!response.ok) return { status: "unavailable" };
69
+ const data = (await response.json()) as { private?: boolean; disabled?: boolean };
70
+ if (data.private || data.disabled) return { status: "not_found" };
71
+ return { status: "ok" };
72
+ } catch {
73
+ return { status: "unavailable" };
74
+ }
75
+ }
src/lib/rate-limit.test.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
3
+
4
+ beforeEach(() => {
5
+ globalThis.__chainkitRateLimits = undefined;
6
+ });
7
+
8
+ describe("checkRateLimit", () => {
9
+ it("blocks after the limit and recovers after the window", () => {
10
+ expect(checkRateLimit("k", 2, 1000, 0).allowed).toBe(true);
11
+ expect(checkRateLimit("k", 2, 1000, 1).allowed).toBe(true);
12
+ const blocked = checkRateLimit("k", 2, 1000, 2);
13
+ expect(blocked.allowed).toBe(false);
14
+ expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
15
+ expect(checkRateLimit("k", 2, 1000, 1001).allowed).toBe(true);
16
+ });
17
+
18
+ it("tracks keys independently", () => {
19
+ checkRateLimit("a", 1, 1000, 0);
20
+ expect(checkRateLimit("b", 1, 1000, 0).allowed).toBe(true);
21
+ });
22
+ });
23
+
24
+ describe("getClientIp", () => {
25
+ it("uses the last x-forwarded-for entry", () => {
26
+ const request = new Request("http://x", { headers: { "x-forwarded-for": "6.6.6.6, 1.2.3.4" } });
27
+ expect(getClientIp(request)).toBe("1.2.3.4");
28
+ });
29
+ });
src/lib/rate-limit.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ type Bucket = { count: number; resetAt: number };
2
+
3
+ declare global {
4
+ var __chainkitRateLimits: Map<string, Bucket> | undefined;
5
+ }
6
+
7
+ const MAX_KEYS = 10_000;
8
+
9
+ function getBuckets() {
10
+ globalThis.__chainkitRateLimits ??= new Map();
11
+ return globalThis.__chainkitRateLimits;
12
+ }
13
+
14
+ export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number };
15
+
16
+ /**
17
+ * Fixed-window limiter held in process memory. On Cloud Run it is per instance
18
+ * and resets on cold start, so treat it as best-effort; put Cloud Armor in front
19
+ * for a hard limit.
20
+ */
21
+ export function checkRateLimit(
22
+ key: string,
23
+ limit: number,
24
+ windowMs: number,
25
+ now = Date.now(),
26
+ ): RateLimitResult {
27
+ const buckets = getBuckets();
28
+ if (buckets.size > MAX_KEYS) {
29
+ for (const [bucketKey, bucket] of buckets) {
30
+ if (bucket.resetAt <= now) buckets.delete(bucketKey);
31
+ }
32
+ // Still full of live buckets: drop the oldest ones rather than grow unbounded.
33
+ if (buckets.size > MAX_KEYS) {
34
+ for (const bucketKey of buckets.keys()) {
35
+ buckets.delete(bucketKey);
36
+ if (buckets.size <= MAX_KEYS / 2) break;
37
+ }
38
+ }
39
+ }
40
+
41
+ const existing = buckets.get(key);
42
+ if (!existing || existing.resetAt <= now) {
43
+ buckets.set(key, { count: 1, resetAt: now + windowMs });
44
+ return { allowed: true, retryAfterSeconds: 0 };
45
+ }
46
+ existing.count += 1;
47
+ if (existing.count > limit) {
48
+ return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)) };
49
+ }
50
+ return { allowed: true, retryAfterSeconds: 0 };
51
+ }
52
+
53
+ /** Client IP as set by Cloud Run / a proxy. Falls back to "unknown". */
54
+ export function getClientIp(request: Request) {
55
+ const forwarded = request.headers.get("x-forwarded-for");
56
+ if (forwarded) {
57
+ // Cloud Run appends the real client address; the last entry is the one we trust.
58
+ const parts = forwarded.split(",").map((part) => part.trim()).filter(Boolean);
59
+ if (parts.length) return parts[parts.length - 1];
60
+ }
61
+ return request.headers.get("x-real-ip") ?? "unknown";
62
+ }
63
+
64
+ export function rateLimitResponse(result: RateLimitResult) {
65
+ return Response.json(
66
+ { error: "Too many requests. Please try again later." },
67
+ { status: 429, headers: { "Retry-After": String(result.retryAfterSeconds) } },
68
+ );
69
+ }
src/lib/tool-types.ts CHANGED
@@ -8,6 +8,10 @@ export const categories = [
8
 
9
  export type ToolCategory = (typeof categories)[number];
10
 
 
 
 
 
11
  export type SupplyChainTool = {
12
  id: string;
13
  slug: string;
@@ -17,6 +21,7 @@ export type SupplyChainTool = {
17
  repoUrl: string;
18
  websiteUrl: string;
19
  submittedBy: string;
 
20
  createdAt: string;
21
  averageRating: number;
22
  ratingCount: number;
 
8
 
9
  export type ToolCategory = (typeof categories)[number];
10
 
11
+ export const toolStatuses = ["pending", "approved", "rejected"] as const;
12
+
13
+ export type ToolStatus = (typeof toolStatuses)[number];
14
+
15
  export type SupplyChainTool = {
16
  id: string;
17
  slug: string;
 
21
  repoUrl: string;
22
  websiteUrl: string;
23
  submittedBy: string;
24
+ status: ToolStatus;
25
  createdAt: string;
26
  averageRating: number;
27
  ratingCount: number;
src/lib/tools.test.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { DuplicateRepoError, getTools, listToolsByStatus, rateTool, setToolStatus, submitTool } from "@/lib/tools";
3
+
4
+ const input = {
5
+ name: "Demo Tool",
6
+ summary: "A summary that is long enough to pass.",
7
+ category: "Planning" as const,
8
+ repoUrl: "https://github.com/Acme/Widget.git",
9
+ websiteUrl: "",
10
+ submittedBy: "Tester",
11
+ };
12
+
13
+ beforeEach(() => {
14
+ globalThis.__chainkitDemoState = undefined;
15
+ });
16
+
17
+ describe("demo store moderation", () => {
18
+ it("keeps new submissions hidden until approved", async () => {
19
+ const tool = await submitTool(input);
20
+ expect(tool.status).toBe("pending");
21
+ expect((await getTools()).some((t) => t.id === tool.id)).toBe(false);
22
+ expect(await rateTool(tool.id, crypto.randomUUID(), 5)).toBeUndefined();
23
+
24
+ await setToolStatus(tool.id, "approved");
25
+ expect((await getTools()).some((t) => t.id === tool.id)).toBe(true);
26
+ expect(await rateTool(tool.id, crypto.randomUUID(), 5)).toBeDefined();
27
+ });
28
+
29
+ it("rejects duplicates regardless of case, .git and trailing slash", async () => {
30
+ await submitTool(input);
31
+ await expect(submitTool({ ...input, repoUrl: "https://github.com/acme/widget/" })).rejects.toBeInstanceOf(
32
+ DuplicateRepoError,
33
+ );
34
+ });
35
+
36
+ it("allows resubmitting a repo after it was rejected", async () => {
37
+ const tool = await submitTool(input);
38
+ await setToolStatus(tool.id, "rejected");
39
+ await expect(submitTool(input)).resolves.toBeDefined();
40
+ expect(await listToolsByStatus("rejected")).toHaveLength(1);
41
+ });
42
+ });
src/lib/tools.ts CHANGED
@@ -1,13 +1,21 @@
1
  import { randomUUID } from "node:crypto";
2
  import { BigQuery } from "@google-cloud/bigquery";
3
- import type { SupplyChainTool, ToolCategory } from "@/lib/tool-types";
4
- import { categories } from "@/lib/tool-types";
 
5
 
6
  type NewTool = Omit<
7
  SupplyChainTool,
8
- "id" | "slug" | "createdAt" | "averageRating" | "ratingCount"
9
  >;
10
 
 
 
 
 
 
 
 
11
  type DemoState = {
12
  tools: SupplyChainTool[];
13
  ratings: Map<string, number>;
@@ -28,6 +36,7 @@ const demoTools: SupplyChainTool[] = [
28
  repoUrl: "https://github.com/MSUSAzureAccelerators/Intelligent-Supply-Chain-Management",
29
  websiteUrl: "",
30
  submittedBy: "Chainkit demo",
 
31
  createdAt: "2026-11-06T10:00:00.000Z",
32
  averageRating: 0,
33
  ratingCount: 0,
@@ -42,6 +51,7 @@ const demoTools: SupplyChainTool[] = [
42
  repoUrl: "https://github.com/frenkowski/SCIMAI-Gym",
43
  websiteUrl: "",
44
  submittedBy: "Chainkit demo",
 
45
  createdAt: "2026-11-06T10:00:00.000Z",
46
  averageRating: 0,
47
  ratingCount: 0,
@@ -56,6 +66,7 @@ const demoTools: SupplyChainTool[] = [
56
  repoUrl: "https://github.com/trydoff/Product-Demand-Forecasting-Using-ML",
57
  websiteUrl: "",
58
  submittedBy: "Chainkit demo",
 
59
  createdAt: "2026-11-06T10:00:00.000Z",
60
  averageRating: 0,
61
  ratingCount: 0,
@@ -70,6 +81,7 @@ const demoTools: SupplyChainTool[] = [
70
  repoUrl: "https://github.com/TimefoldAI/timefold-solver",
71
  websiteUrl: "",
72
  submittedBy: "Chainkit demo",
 
73
  createdAt: "2026-11-06T10:00:00.000Z",
74
  averageRating: 0,
75
  ratingCount: 0,
@@ -84,6 +96,7 @@ const demoTools: SupplyChainTool[] = [
84
  repoUrl: "https://github.com/workkrishnpatel/supply-chain-late-delivery-prediction",
85
  websiteUrl: "",
86
  submittedBy: "Chainkit demo",
 
87
  createdAt: "2026-11-06T10:00:00.000Z",
88
  averageRating: 0,
89
  ratingCount: 0,
@@ -98,6 +111,7 @@ const demoTools: SupplyChainTool[] = [
98
  repoUrl: "https://github.com/ArwaElsayed5/Graduation-Project",
99
  websiteUrl: "",
100
  submittedBy: "Chainkit demo",
 
101
  createdAt: "2026-11-06T10:00:00.000Z",
102
  averageRating: 0,
103
  ratingCount: 0,
@@ -112,6 +126,7 @@ const demoTools: SupplyChainTool[] = [
112
  repoUrl: "https://github.com/thenishantraj/fraudlens-ai",
113
  websiteUrl: "",
114
  submittedBy: "Chainkit demo",
 
115
  createdAt: "2026-11-06T10:00:00.000Z",
116
  averageRating: 0,
117
  ratingCount: 0,
@@ -161,6 +176,10 @@ function ratingTable(projectId: string, dataset: string) {
161
  return `\`${projectId}.${dataset}.tool_ratings\``;
162
  }
163
 
 
 
 
 
164
  function isToolCategory(value: string): value is ToolCategory {
165
  return categories.some((item) => item === value);
166
  }
@@ -170,6 +189,10 @@ function mapBigQueryRow(row: Record<string, unknown>): SupplyChainTool {
170
  if (!isToolCategory(category)) {
171
  throw new Error(`BigQuery returned an unsupported category: ${category}`);
172
  }
 
 
 
 
173
  return {
174
  id: String(row.id),
175
  slug: String(row.slug),
@@ -179,12 +202,15 @@ function mapBigQueryRow(row: Record<string, unknown>): SupplyChainTool {
179
  repoUrl: String(row.repoUrl),
180
  websiteUrl: String(row.websiteUrl ?? ""),
181
  submittedBy: String(row.submittedBy),
 
182
  createdAt: new Date(String(row.createdAt)).toISOString(),
183
  averageRating: Number(row.averageRating ?? 0),
184
  ratingCount: Number(row.ratingCount ?? 0),
185
  };
186
  }
187
 
 
 
188
  function buildToolQuery(
189
  projectId: string,
190
  dataset: string,
@@ -201,6 +227,7 @@ function buildToolQuery(
201
  t.repo_url AS repoUrl,
202
  t.website_url AS websiteUrl,
203
  t.submitted_by AS submittedBy,
 
204
  t.created_at AS createdAt,
205
  COALESCE(AVG(r.rating), 0) AS averageRating,
206
  COUNT(r.rating) AS ratingCount
@@ -210,36 +237,75 @@ function buildToolQuery(
210
  ${where}
211
  GROUP BY
212
  t.tool_id, t.slug, t.name, t.summary, t.category, t.repo_url,
213
- t.website_url, t.submitted_by, t.created_at
214
  ${order}
215
  `;
216
  }
217
 
218
  export async function getTools(): Promise<SupplyChainTool[]> {
219
  const bigQuery = getBigQuery();
220
- if (!bigQuery) return getDemoState().tools;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
  const [rows] = await bigQuery.client.query({
223
  query: buildToolQuery(
224
  bigQuery.projectId,
225
  bigQuery.dataset,
226
- "",
227
  "ORDER BY t.created_at DESC",
228
  ),
 
229
  });
230
  return rows.map(mapBigQueryRow);
231
  }
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  export async function getToolBySlug(slug: string) {
234
  const bigQuery = getBigQuery();
235
  if (!bigQuery) {
236
- return getDemoState().tools.find((tool) => tool.slug === slug);
 
 
237
  }
238
  const [rows] = await bigQuery.client.query({
239
  query: `${buildToolQuery(
240
  bigQuery.projectId,
241
  bigQuery.dataset,
242
- "WHERE t.slug = @slug",
243
  )} LIMIT 1`,
244
  params: { slug },
245
  });
@@ -249,20 +315,41 @@ export async function getToolBySlug(slug: string) {
249
  async function getToolById(toolId: string) {
250
  const bigQuery = getBigQuery();
251
  if (!bigQuery) {
252
- return getDemoState().tools.find((tool) => tool.id === toolId);
 
 
253
  }
254
  const [rows] = await bigQuery.client.query({
255
  query: `${buildToolQuery(
256
  bigQuery.projectId,
257
  bigQuery.dataset,
258
- "WHERE t.tool_id = @toolId",
259
  )} LIMIT 1`,
260
  params: { toolId },
261
  });
262
  return rows[0] ? mapBigQueryRow(rows[0]) : undefined;
263
  }
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  export async function submitTool(input: NewTool) {
 
 
 
266
  const createdAt = new Date().toISOString();
267
  const id = randomUUID();
268
  const slugBase =
@@ -275,8 +362,10 @@ export async function submitTool(input: NewTool) {
275
  .slice(0, 48) || "supply-chain-tool";
276
  const tool: SupplyChainTool = {
277
  ...input,
 
278
  id,
279
  slug: `${slugBase}-${id.slice(0, 8)}`,
 
280
  createdAt,
281
  averageRating: 0,
282
  ratingCount: 0,
@@ -284,16 +373,33 @@ export async function submitTool(input: NewTool) {
284
  const bigQuery = getBigQuery();
285
 
286
  if (!bigQuery) {
287
- getDemoState().tools.unshift(tool);
 
 
 
 
 
 
 
288
  return tool;
289
  }
290
 
291
- await bigQuery.client.query({
 
292
  query: `
293
  INSERT INTO ${toolTable(bigQuery.projectId, bigQuery.dataset)}
294
- (tool_id, slug, name, summary, category, repo_url, website_url, submitted_by, created_at)
295
- VALUES
296
- (@id, @slug, @name, @summary, @category, @repoUrl, @websiteUrl, @submittedBy, TIMESTAMP(@createdAt))
 
 
 
 
 
 
 
 
 
297
  `,
298
  params: {
299
  id: tool.id,
@@ -302,11 +408,14 @@ export async function submitTool(input: NewTool) {
302
  summary: tool.summary,
303
  category: tool.category,
304
  repoUrl: tool.repoUrl,
 
305
  websiteUrl: tool.websiteUrl,
306
  submittedBy: tool.submittedBy,
307
  createdAt: tool.createdAt,
308
  },
309
  });
 
 
310
  return tool;
311
  }
312
 
@@ -314,7 +423,9 @@ export async function rateTool(toolId: string, voterId: string, rating: number)
314
  const bigQuery = getBigQuery();
315
  if (!bigQuery) {
316
  const state = getDemoState();
317
- const tool = state.tools.find((candidate) => candidate.id === toolId);
 
 
318
  if (!tool) return undefined;
319
 
320
  const key = `${toolId}:${voterId}`;
 
1
  import { randomUUID } from "node:crypto";
2
  import { BigQuery } from "@google-cloud/bigquery";
3
+ import type { SupplyChainTool, ToolCategory, ToolStatus } from "@/lib/tool-types";
4
+ import { categories, toolStatuses } from "@/lib/tool-types";
5
+ import { parseGithubRepoUrl } from "@/lib/github";
6
 
7
  type NewTool = Omit<
8
  SupplyChainTool,
9
+ "id" | "slug" | "createdAt" | "averageRating" | "ratingCount" | "status"
10
  >;
11
 
12
+ export class DuplicateRepoError extends Error {
13
+ constructor() {
14
+ super("This repository has already been submitted.");
15
+ this.name = "DuplicateRepoError";
16
+ }
17
+ }
18
+
19
  type DemoState = {
20
  tools: SupplyChainTool[];
21
  ratings: Map<string, number>;
 
36
  repoUrl: "https://github.com/MSUSAzureAccelerators/Intelligent-Supply-Chain-Management",
37
  websiteUrl: "",
38
  submittedBy: "Chainkit demo",
39
+ status: "approved",
40
  createdAt: "2026-11-06T10:00:00.000Z",
41
  averageRating: 0,
42
  ratingCount: 0,
 
51
  repoUrl: "https://github.com/frenkowski/SCIMAI-Gym",
52
  websiteUrl: "",
53
  submittedBy: "Chainkit demo",
54
+ status: "approved",
55
  createdAt: "2026-11-06T10:00:00.000Z",
56
  averageRating: 0,
57
  ratingCount: 0,
 
66
  repoUrl: "https://github.com/trydoff/Product-Demand-Forecasting-Using-ML",
67
  websiteUrl: "",
68
  submittedBy: "Chainkit demo",
69
+ status: "approved",
70
  createdAt: "2026-11-06T10:00:00.000Z",
71
  averageRating: 0,
72
  ratingCount: 0,
 
81
  repoUrl: "https://github.com/TimefoldAI/timefold-solver",
82
  websiteUrl: "",
83
  submittedBy: "Chainkit demo",
84
+ status: "approved",
85
  createdAt: "2026-11-06T10:00:00.000Z",
86
  averageRating: 0,
87
  ratingCount: 0,
 
96
  repoUrl: "https://github.com/workkrishnpatel/supply-chain-late-delivery-prediction",
97
  websiteUrl: "",
98
  submittedBy: "Chainkit demo",
99
+ status: "approved",
100
  createdAt: "2026-11-06T10:00:00.000Z",
101
  averageRating: 0,
102
  ratingCount: 0,
 
111
  repoUrl: "https://github.com/ArwaElsayed5/Graduation-Project",
112
  websiteUrl: "",
113
  submittedBy: "Chainkit demo",
114
+ status: "approved",
115
  createdAt: "2026-11-06T10:00:00.000Z",
116
  averageRating: 0,
117
  ratingCount: 0,
 
126
  repoUrl: "https://github.com/thenishantraj/fraudlens-ai",
127
  websiteUrl: "",
128
  submittedBy: "Chainkit demo",
129
+ status: "approved",
130
  createdAt: "2026-11-06T10:00:00.000Z",
131
  averageRating: 0,
132
  ratingCount: 0,
 
176
  return `\`${projectId}.${dataset}.tool_ratings\``;
177
  }
178
 
179
+ function isToolStatus(value: string): value is ToolStatus {
180
+ return toolStatuses.some((item) => item === value);
181
+ }
182
+
183
  function isToolCategory(value: string): value is ToolCategory {
184
  return categories.some((item) => item === value);
185
  }
 
189
  if (!isToolCategory(category)) {
190
  throw new Error(`BigQuery returned an unsupported category: ${category}`);
191
  }
192
+ const status = String(row.status ?? "approved");
193
+ if (!isToolStatus(status)) {
194
+ throw new Error(`BigQuery returned an unsupported status: ${status}`);
195
+ }
196
  return {
197
  id: String(row.id),
198
  slug: String(row.slug),
 
202
  repoUrl: String(row.repoUrl),
203
  websiteUrl: String(row.websiteUrl ?? ""),
204
  submittedBy: String(row.submittedBy),
205
+ status,
206
  createdAt: new Date(String(row.createdAt)).toISOString(),
207
  averageRating: Number(row.averageRating ?? 0),
208
  ratingCount: Number(row.ratingCount ?? 0),
209
  };
210
  }
211
 
212
+ const APPROVED_ONLY = "WHERE COALESCE(t.status, 'approved') = 'approved'";
213
+
214
  function buildToolQuery(
215
  projectId: string,
216
  dataset: string,
 
227
  t.repo_url AS repoUrl,
228
  t.website_url AS websiteUrl,
229
  t.submitted_by AS submittedBy,
230
+ COALESCE(t.status, 'approved') AS status,
231
  t.created_at AS createdAt,
232
  COALESCE(AVG(r.rating), 0) AS averageRating,
233
  COUNT(r.rating) AS ratingCount
 
237
  ${where}
238
  GROUP BY
239
  t.tool_id, t.slug, t.name, t.summary, t.category, t.repo_url,
240
+ t.website_url, t.submitted_by, t.status, t.created_at
241
  ${order}
242
  `;
243
  }
244
 
245
  export async function getTools(): Promise<SupplyChainTool[]> {
246
  const bigQuery = getBigQuery();
247
+ if (!bigQuery) return getDemoState().tools.filter((tool) => tool.status === "approved");
248
+
249
+ const [rows] = await bigQuery.client.query({
250
+ query: buildToolQuery(
251
+ bigQuery.projectId,
252
+ bigQuery.dataset,
253
+ APPROVED_ONLY,
254
+ "ORDER BY t.created_at DESC",
255
+ ),
256
+ });
257
+ return rows.map(mapBigQueryRow);
258
+ }
259
+
260
+ /** Moderation view: tools in a given status, newest first. */
261
+ export async function listToolsByStatus(status: ToolStatus): Promise<SupplyChainTool[]> {
262
+ const bigQuery = getBigQuery();
263
+ if (!bigQuery) return getDemoState().tools.filter((tool) => tool.status === status);
264
 
265
  const [rows] = await bigQuery.client.query({
266
  query: buildToolQuery(
267
  bigQuery.projectId,
268
  bigQuery.dataset,
269
+ "WHERE COALESCE(t.status, 'approved') = @status",
270
  "ORDER BY t.created_at DESC",
271
  ),
272
+ params: { status },
273
  });
274
  return rows.map(mapBigQueryRow);
275
  }
276
 
277
+ export async function setToolStatus(toolId: string, status: ToolStatus) {
278
+ const bigQuery = getBigQuery();
279
+ if (!bigQuery) {
280
+ const tool = getDemoState().tools.find((candidate) => candidate.id === toolId);
281
+ if (!tool) return false;
282
+ tool.status = status;
283
+ return true;
284
+ }
285
+ const [job] = await bigQuery.client.createQueryJob({
286
+ query: `
287
+ UPDATE ${toolTable(bigQuery.projectId, bigQuery.dataset)}
288
+ SET status = @status
289
+ WHERE tool_id = @toolId
290
+ `,
291
+ params: { toolId, status },
292
+ });
293
+ await job.getQueryResults();
294
+ return (await affectedRows(job)) > 0;
295
+ }
296
+
297
  export async function getToolBySlug(slug: string) {
298
  const bigQuery = getBigQuery();
299
  if (!bigQuery) {
300
+ return getDemoState().tools.find(
301
+ (tool) => tool.slug === slug && tool.status === "approved",
302
+ );
303
  }
304
  const [rows] = await bigQuery.client.query({
305
  query: `${buildToolQuery(
306
  bigQuery.projectId,
307
  bigQuery.dataset,
308
+ `${APPROVED_ONLY} AND t.slug = @slug`,
309
  )} LIMIT 1`,
310
  params: { slug },
311
  });
 
315
  async function getToolById(toolId: string) {
316
  const bigQuery = getBigQuery();
317
  if (!bigQuery) {
318
+ return getDemoState().tools.find(
319
+ (tool) => tool.id === toolId && tool.status === "approved",
320
+ );
321
  }
322
  const [rows] = await bigQuery.client.query({
323
  query: `${buildToolQuery(
324
  bigQuery.projectId,
325
  bigQuery.dataset,
326
+ `${APPROVED_ONLY} AND t.tool_id = @toolId`,
327
  )} LIMIT 1`,
328
  params: { toolId },
329
  });
330
  return rows[0] ? mapBigQueryRow(rows[0]) : undefined;
331
  }
332
 
333
+ async function affectedRows(job: { getMetadata: () => Promise<unknown[]> }) {
334
+ const [metadata] = (await job.getMetadata()) as [
335
+ { statistics?: { query?: { numDmlAffectedRows?: string } } },
336
+ ];
337
+ return Number(metadata.statistics?.query?.numDmlAffectedRows ?? 0);
338
+ }
339
+
340
+ /**
341
+ * Stores a new submission as `pending`. Throws DuplicateRepoError when the same
342
+ * repository (case-insensitive, ignoring `.git` and trailing slash) is already
343
+ * listed or awaiting review.
344
+ *
345
+ * BigQuery cannot enforce uniqueness, so the check is part of the INSERT
346
+ * statement. Two exactly simultaneous submissions can still race; the per-IP
347
+ * rate limit makes that impractical to exploit.
348
+ */
349
  export async function submitTool(input: NewTool) {
350
+ const repo = parseGithubRepoUrl(input.repoUrl);
351
+ if (!repo) throw new Error("repoUrl must be a GitHub repository URL.");
352
+
353
  const createdAt = new Date().toISOString();
354
  const id = randomUUID();
355
  const slugBase =
 
362
  .slice(0, 48) || "supply-chain-tool";
363
  const tool: SupplyChainTool = {
364
  ...input,
365
+ repoUrl: repo.canonicalUrl,
366
  id,
367
  slug: `${slugBase}-${id.slice(0, 8)}`,
368
+ status: "pending",
369
  createdAt,
370
  averageRating: 0,
371
  ratingCount: 0,
 
373
  const bigQuery = getBigQuery();
374
 
375
  if (!bigQuery) {
376
+ const state = getDemoState();
377
+ const duplicate = state.tools.some(
378
+ (existing) =>
379
+ existing.status !== "rejected" &&
380
+ parseGithubRepoUrl(existing.repoUrl)?.repoKey === repo.repoKey,
381
+ );
382
+ if (duplicate) throw new DuplicateRepoError();
383
+ state.tools.unshift(tool);
384
  return tool;
385
  }
386
 
387
+ // The REGEXP_REPLACE branch also catches rows written before repo_key existed.
388
+ const [job] = await bigQuery.client.createQueryJob({
389
  query: `
390
  INSERT INTO ${toolTable(bigQuery.projectId, bigQuery.dataset)}
391
+ (tool_id, slug, name, summary, category, repo_url, repo_key, website_url, submitted_by, status, created_at)
392
+ SELECT
393
+ @id, @slug, @name, @summary, @category, @repoUrl, @repoKey, @websiteUrl, @submittedBy, 'pending', TIMESTAMP(@createdAt)
394
+ FROM (SELECT 1)
395
+ WHERE NOT EXISTS (
396
+ SELECT 1 FROM ${toolTable(bigQuery.projectId, bigQuery.dataset)} AS existing
397
+ WHERE COALESCE(existing.status, 'approved') != 'rejected'
398
+ AND (
399
+ existing.repo_key = @repoKey
400
+ OR LOWER(REGEXP_REPLACE(existing.repo_url, r'(\.git)?/?$', '')) = CONCAT('https://github.com/', @repoKey)
401
+ )
402
+ )
403
  `,
404
  params: {
405
  id: tool.id,
 
408
  summary: tool.summary,
409
  category: tool.category,
410
  repoUrl: tool.repoUrl,
411
+ repoKey: repo.repoKey,
412
  websiteUrl: tool.websiteUrl,
413
  submittedBy: tool.submittedBy,
414
  createdAt: tool.createdAt,
415
  },
416
  });
417
+ await job.getQueryResults();
418
+ if ((await affectedRows(job)) === 0) throw new DuplicateRepoError();
419
  return tool;
420
  }
421
 
 
423
  const bigQuery = getBigQuery();
424
  if (!bigQuery) {
425
  const state = getDemoState();
426
+ const tool = state.tools.find(
427
+ (candidate) => candidate.id === toolId && candidate.status === "approved",
428
+ );
429
  if (!tool) return undefined;
430
 
431
  const key = `${toolId}:${voterId}`;
src/lib/voter.test.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { createVoterToken, verifyVoterToken } from "@/lib/voter";
3
+
4
+ describe("voter token", () => {
5
+ it("round-trips a signed token", () => {
6
+ const { voterId, token } = createVoterToken();
7
+ expect(verifyVoterToken(token)).toBe(voterId);
8
+ });
9
+ it("rejects forged, tampered and missing tokens", () => {
10
+ const { token } = createVoterToken();
11
+ expect(verifyVoterToken(`${crypto.randomUUID()}.${token.split(".")[1]}`)).toBeUndefined();
12
+ expect(verifyVoterToken(`${token}x`)).toBeUndefined();
13
+ expect(verifyVoterToken(crypto.randomUUID())).toBeUndefined();
14
+ expect(verifyVoterToken(undefined)).toBeUndefined();
15
+ });
16
+ });
src/lib/voter.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
2
+
3
+ export const VOTER_COOKIE = "chainkit-voter";
4
+ export const VOTER_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
5
+
6
+ let devSecret: string | undefined;
7
+
8
+ function getSecret() {
9
+ const secret = process.env.VOTER_SECRET;
10
+ if (secret) return secret;
11
+ if (process.env.NODE_ENV === "production") {
12
+ throw new Error("VOTER_SECRET must be set in production.");
13
+ }
14
+ devSecret ??= randomUUID();
15
+ return devSecret;
16
+ }
17
+
18
+ function sign(voterId: string) {
19
+ return createHmac("sha256", getSecret()).update(voterId).digest("base64url");
20
+ }
21
+
22
+ export function createVoterToken(voterId = randomUUID()) {
23
+ return { voterId, token: `${voterId}.${sign(voterId)}` };
24
+ }
25
+
26
+ /** Returns the voter UUID when the token carries a valid signature. */
27
+ export function verifyVoterToken(token: string | undefined) {
28
+ if (!token) return undefined;
29
+ const separator = token.indexOf(".");
30
+ if (separator < 1) return undefined;
31
+ const voterId = token.slice(0, separator);
32
+ const signature = Buffer.from(token.slice(separator + 1));
33
+ const expected = Buffer.from(sign(voterId));
34
+ if (signature.length !== expected.length || !timingSafeEqual(signature, expected)) {
35
+ return undefined;
36
+ }
37
+ return voterId;
38
+ }
vitest.config.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { fileURLToPath } from "node:url";
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ export default defineConfig({
5
+ resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
6
+ test: { include: ["src/**/*.test.ts"] },
7
+ });