Add spam protection, moderation and repo verification
#4
by arshvinw5 - opened
- .env.example +10 -0
- .gitignore +1 -0
- README.md +28 -3
- bun.lock +0 -0
- infra/bigquery_schema.sql +17 -0
- next-env.d.ts +0 -7
- package-lock.json +0 -0
- package.json +4 -2
- src/app/api/admin/tools/[id]/route.ts +21 -0
- src/app/api/admin/tools/route.ts +17 -0
- src/app/api/ratings/route.ts +29 -6
- src/app/api/tools/route.ts +41 -19
- src/app/directory.tsx +4 -11
- src/app/tool-rating.tsx +1 -11
- src/lib/admin-auth.ts +22 -0
- src/lib/github.test.ts +45 -0
- src/lib/github.ts +75 -0
- src/lib/rate-limit.test.ts +29 -0
- src/lib/rate-limit.ts +69 -0
- src/lib/tool-types.ts +5 -0
- src/lib/tools.test.ts +42 -0
- src/lib/tools.ts +127 -16
- src/lib/voter.test.ts +16 -0
- src/lib/voter.ts +38 -0
- vitest.config.ts +7 -0
.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
|
| 69 |
-
-
|
|
|
|
|
|
|
| 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
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 61 |
-
return NextResponse.json(
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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">
|
| 358 |
<h2 id="modal-heading">Thanks for sharing.</h2>
|
| 359 |
-
<p>Your project
|
| 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
|
| 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(
|
|
|
|
|
|
|
| 237 |
}
|
| 238 |
const [rows] = await bigQuery.client.query({
|
| 239 |
query: `${buildToolQuery(
|
| 240 |
bigQuery.projectId,
|
| 241 |
bigQuery.dataset,
|
| 242 |
-
|
| 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(
|
|
|
|
|
|
|
| 253 |
}
|
| 254 |
const [rows] = await bigQuery.client.query({
|
| 255 |
query: `${buildToolQuery(
|
| 256 |
bigQuery.projectId,
|
| 257 |
bigQuery.dataset,
|
| 258 |
-
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
return tool;
|
| 289 |
}
|
| 290 |
|
| 291 |
-
|
|
|
|
| 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 |
-
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 |
+
});
|