diff --git a/docs/16-capstone-checker-and-certificates.mdx b/docs/16-capstone-checker-and-certificates.mdx
new file mode 100644
index 000000000..1f1d30ab8
--- /dev/null
+++ b/docs/16-capstone-checker-and-certificates.mdx
@@ -0,0 +1,273 @@
+---
+title: Capstone checker and certificates
+status: approved 2026-11-06, built
+---
+
+# Capstone checker and certificates
+
+**Reads:** [`05-review-queue.mdx`](05-review-queue.mdx), [`09-security.mdx`](09-security.mdx), [`15-content-separation.mdx`](15-content-separation.mdx)
+
+For the Dev Weekends guided-lab tracks (Level 1, 2 and 3). A learner submits a capstone, our
+own AI says how complete it is and what is left, a mentor confirms, and a passed track earns a
+certificate the learner can add to LinkedIn.
+
+## What the learner sees
+
+1. On the capstone chapter, a **Capstone check** block asks for a GitHub repo URL (and a live URL
+ for Level 2).
+2. They press **Check my capstone**. Within about a minute they see:
+ - **Complete** or **Not complete yet**, and a percentage.
+ - **What is left**: one card per unmet criterion, each with numbered steps.
+ - **Quality notes**: what is good and what would make it better.
+ - A line saying a mentor will confirm the result.
+3. They fix things and press **Check again**. Every attempt is kept as history.
+
+## What the mentor sees
+
+A check reaches the existing review queue when the AI says it is complete, or when the learner
+presses **Ask my mentor to look**. There is one waiting item per learner per capstone: a newer
+check replaces the one already waiting. The review page shows the full report: automated check
+results, per-criterion scores with the evidence the model cited, the commit, the model, tokens
+and cost, plus every earlier check. The review form is prefilled from the AI result, so
+**Approve** confirms it, and changing the feedback, score or verdict overrides it. This writes
+the ordinary `Review` row, so the queue, notifications and needs-revision loop work as today. **The AI never passes a learner on
+its own.** Only a mentor-confirmed pass counts toward a certificate.
+
+## How a check runs
+
+```
+submit -> automated checks -> (stop early if a hard gate fails) -> Sonnet grade -> report
+```
+
+**Automated checks** (no model, no cost), defined per pack:
+
+| Check | How |
+| --- | --- |
+| Repo reachable | GitHub API: repo exists and is public, default branch, head commit SHA |
+| Builds and tests pass | Status of the latest GitHub Actions run on that commit. We never run learner code on our servers. Each capstone template ships a CI workflow. |
+| Tags | Required tag pattern exists (for example `v1.*`) |
+| Files | Required paths exist (README, workflow, tests folder) |
+| Live URL responds | HTTPS GET returns 2xx within 5 s (Level 2) |
+| Live page contains | Optional text on the live page (for example the analytics snippet) |
+| Course files unchanged (`files_match`) | Named files (workflow, supplied tests) are byte-identical, line endings aside, to the template at a tag: `against: https://github.com/dw/template@v1`. Stops the run by default, so an edited workflow or test cannot fake a green build. |
+| File contains (`file_contains`) | A file has an expected line, for example a pinned `uses:` of a course-owned reusable workflow |
+| Named check run (`check_run`) | One named GitHub check concluded with success on the commit, so a number can come from CI rather than a committed file |
+
+Any check can set `stop: true` or `stop: false`; by default the repo, CI and course-file checks stop the run.
+If the repo is unreachable, CI is red or a course file was changed, the learner gets instant steps and **no model call is
+made**.
+
+**AI grade.** We send the rubric pack, the automated results, and a bounded snapshot of the repo
+(file tree, README, and the files the pack names, trimmed to a token budget) to
+`claude-sonnet-5-5` with a JSON output schema. The output contract:
+
+```ts
+{
+ completed: boolean, // all required criteria met and percent >= pack.passPercent
+ percent: number, // 0..100, weighted from criterion scores
+ criteria: [{ id, score, max, met, evidence: string[], missing: string[] }],
+ whatIsLeft: [{ criterionId, title, steps: string[] }],
+ qualityNotes: { strengths: string[], improvements: string[] },
+ confidence: "high" | "medium" | "low",
+ integrityFlags: string[] // mentor only; any flag sends the check to the mentor
+}
+```
+
+The server recomputes `percent` and `completed` from the criterion scores and the hard gates,
+so the model cannot claim 100% with a failing gate. A criterion tied to a failed automated
+check is capped at 0.
+
+**Same commit, same pack version: no new call.** The previous result is returned free.
+
+## Cost
+
+Sonnet 5.5 is $2 per million input tokens and $10 per million output tokens. A typical check is
+about 30k input and 4k output, so **about $0.10**. Caps, all counted in the database like the
+chapter Q&A limits:
+
+| Limit | Default | Env |
+| --- | --- | --- |
+| Input budget per check | 40k tokens (snapshot trimmed to fit) | `CAPSTONE_MAX_INPUT_TOKENS` |
+| Output per check | 8k tokens | `CAPSTONE_MAX_OUTPUT_TOKENS` |
+| Worst case per check | about $0.16 | |
+| Checks per learner per block per day | 3 | `CAPSTONE_LEARNER_DAILY_LIMIT` |
+| Checks platform-wide per day | 150 (about $15 worst case) | `CAPSTONE_GLOBAL_DAILY_LIMIT` |
+
+It uses the same `ANTHROPIC_API_KEY` as the chapter questions and is on whenever that key is set
+(`CAPSTONE_CHECK_ENABLED=false` switches it off). Only courses with a capstone block use it. Tokens and cost are
+stored on every check.
+
+## Rubric packs
+
+Packs live with the course in Edorax-core and travel through the normal publish path. The
+importer reads them, validates them, and stores them in the block's server-only config.
+
+```
+courses//capstone/
+ rubric.yaml
+ calibration/.yaml
+ calibration// # frozen repo snapshot for the example (optional)
+```
+
+```yaml
+# rubric.yaml
+pack: dw-level-2
+version: 1
+title: Personal site and outreach engine
+passPercent: 70
+submission: { repo: required, liveUrl: required }
+automated:
+ - { check: repo_public }
+ - { check: ci_green }
+ - { check: tag, pattern: "v1.*" }
+ - { check: files, paths: [README.md, ".github/workflows/*.yml"] }
+ - { check: live_ok }
+ - { check: live_contains, text: "data-analytics" }
+snapshot: # which files the grader reads, in priority order
+ include: ["README.md", "src/**", "docs/**"]
+ exclude: ["**/*.lock", "**/node_modules/**"]
+criteria:
+ - id: proposal-links
+ title: Tracked proposal links
+ weight: 20
+ required: true
+ gate: [live_ok] # capped at 0 if these automated checks fail
+ lookFor: Each proposal has its own URL and visits are recorded per link.
+ levels:
+ 0: No proposal links.
+ 2: Links exist but visits are not tracked.
+ 4: Links tracked and visible in an analytics view.
+oralExam: { enabled: false } # Level 3 hook, see below
+```
+
+```yaml
+# calibration/strong-submission.yaml
+repo: https://github.com/example/site@3f2a9c1 # pinned commit, or
+snapshot: ./strong-submission/ # frozen files plus automated.json
+liveUrl: https://example.dev
+expected: { completed: true, percent: 85, tolerance: 10 }
+note: Clean site, tracked links, weak README.
+```
+
+The block in a chapter is a fence; the importer reads the pack file, validates it and stores it
+in the block's server-only config (calibration files never ship to learners):
+
+````md
+```capstone
+title: Ship your personal site
+intro: Paste your repo and your live site. The check takes about a minute.
+pack: capstone/rubric.yaml
+```
+````
+
+Check ids default to the check name (`live_ok`), so a criterion gates on `[live_ok]`. Give an
+id when a pack has two checks of the same kind. `passLevel` (default: full marks) sets the level
+that counts as met. A snapshot example can include `live.html` so calibration never touches the
+network.
+
+## Calibration test
+
+- **CI (free):** loader and schema tests for every pack, plus grading-pipeline tests with a
+ recorded model response, so gates, recomputed percent and caps are covered without the API.
+- A course with several packs (`capstone/capstone-1.yaml`, `capstone-2.yaml`) keeps each pack's
+ examples in `calibration//`; a lone `capstone/rubric.yaml` uses `calibration/`.
+- **Live (paid, run by hand):** `pnpm --dir server capstone:calibrate --pack ` runs every
+ calibration example through the real checker and fails if `completed` differs or `percent`
+ falls outside `tolerance`. It prints the cost before and after. Run it when a pack changes.
+
+## Certificates
+
+**When:** a learner's enrollment is complete and every capstone check in the course has a
+mentor-confirmed pass. Issued once per enrollment; a mentor or admin can revoke.
+
+**What it says:** the learner's name, the track title (for example "Level 2: Personal Site and
+Outreach Engine"), "Dev Weekends", the issue date, the skills covered, a unique id such as
+`DW-L2-7K3F9QXM`, and the course's trainer as issuer: the instructor name the catalog shows for
+the course (Zeeshan Adil for the Dev Weekends tracks). `certificate.issuer.name` in course.yaml
+overrides it, for example Aqib Nawab on the multi-vendor marketplace course.
+
+**Configured per course** in `course.yaml`:
+
+```yaml
+certificate:
+ program: Dev Weekends Mentorship
+ title: "Level 2: Personal Site and Outreach Engine"
+ code: L2
+ skills: [Personal site, Outreach engine, Link tracking, Web analytics]
+```
+
+**Public verify page** at `/verify/`: no sign-in, shows the certificate, its status (valid
+or revoked), skills, and the capstone repo link. It sets Open Graph tags so a shared link shows
+the certificate image.
+
+**Add to LinkedIn:** one button that opens LinkedIn's add-to-profile form pre-filled:
+
+```
+https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME
+ &name=&organizationName=Edorax&issueYear=2026&issueMonth=11
+ &certUrl=&certId=
+```
+
+Plus **Share on LinkedIn** (post with the verify link) and **Download image** (1200 x 627 PNG
+rendered on the server with `next/og`).
+
+**Look:** Edorax type (Unbounded headings, DM Sans body), white card with a 1px line border and
+square corners, ink text on paper, no gradients, no ribbons, no seals, no script fonts. The Dev
+Weekends wordmark with its seven week bars carries the issuer mark in black and white. One
+cobalt element at most (the verify link). It should read like a well set document, not a
+template.
+
+## Level 3 oral exam (later phase)
+
+Each check is a list of stages: `automated`, `ai_grade`, and later `oral_exam`. A pack with
+`oralExam.enabled: true` will require a passed oral stage, built on the existing AI interview
+block, before the mentor can confirm. Phase 1 ships the stage interface and the pack field only.
+
+## Security
+
+- Fetching learner URLs is new for Edorax (the review queue doc says we do not). Rules: HTTPS
+ only, DNS resolved and private, loopback, link-local and metadata ranges refused (also after
+ redirects), GET only, no cookies, 5 s timeout, 512 KB cap.
+- GitHub is read through its API (repo, commit, tags, CI) and its raw file host (file contents), with no token by default.
+- Repo text is untrusted. The prompt treats it as data, output is schema-bound, the server
+ recomputes the score, gates are enforced in code, and a mentor confirms every pass.
+- Rubric packs stay server-only, like every block config.
+
+## Data model
+
+- `BlockType.CAPSTONE_CHECK`.
+- `CapstoneCheck`: one row per attempt, linked to the `Response` it creates. Holds repo URL,
+ live URL, commit SHA, pack id and version, status (`QUEUED`, `RUNNING`, `DONE`, `FAILED`),
+ automated results, AI report, percent, completed, model, tokens, cost. Mentor decisions use the
+ existing `Review`.
+- `Certificate`: public id, user, course, enrollment (unique), recipient name and title snapshot,
+ skills, issued at, revoked at and reason.
+
+## Needs keys or approval
+
+| Item | Who |
+| --- | --- |
+| Nothing for the AI key: the checker uses the `ANTHROPIC_API_KEY` already set for chapter Q&A | None |
+| `GITHUB_TOKEN` is optional. Files come from GitHub's raw host, so a check uses about five API calls and the 60 an hour limit without a token covers about 12 checks an hour. A read-only token raises that to 5,000 calls if needed | Optional |
+| LinkedIn lists Edorax, never Dev Weekends, as the issuer. When the platform has its own LinkedIn page, set its id as `CERTIFICATE_LINKEDIN_ORG_ID`; until then LinkedIn gets "Edorax" as plain text | Later |
+| Optional title under the trainer's name: `certificate.issuer.title` in course.yaml | Optional |
+| Daily caps above (env, defaults in the cost table) | User |
+| One live calibration run per level pack (`pnpm capstone:calibrate --pack --live --record`, about $0.20 per example) | Level sessions, with the API key |
+| Each level's capstone template ships a CI workflow that builds and runs tests | Level sessions |
+
+## Known limits and follow-ups
+
+- The desktop app's reader does not draw the capstone block yet; it needs the same block in
+ `packages/learning-ui` (the web app has it). Learners can use the web app meanwhile.
+- Certificate eligibility counts every capstone block in the course. It does not yet filter by the
+ learner's course variant or release.
+- Claude's server-side refusal fallback is not enabled. A refused grade fails the check with a
+ message telling the learner to send it to their mentor.
+- Checks run inside the API process. A restart marks running checks as interrupted after five
+ minutes so the learner can check again. A job queue can replace this if volume grows.
+- Level 3's oral exam stage is the next phase (see above).
+- Asked for by Level 2, not built yet: the checker's own screenshots of the live URL at 375 and
+ 1280 px for the grader. It needs a headless browser outside the API process.
+- Asked for by Level 1, not built yet: reading CI for the commit a release tag points at, instead of
+ the default branch head.
+
diff --git a/frontend/public/brand/dw/dw-wordmark-black.png b/frontend/public/brand/dw/dw-wordmark-black.png
new file mode 100644
index 000000000..c04ad4827
Binary files /dev/null and b/frontend/public/brand/dw/dw-wordmark-black.png differ
diff --git a/frontend/src/app/(teach)/review/[responseId]/page.tsx b/frontend/src/app/(teach)/review/[responseId]/page.tsx
index 241c4129e..3aaad994e 100644
--- a/frontend/src/app/(teach)/review/[responseId]/page.tsx
+++ b/frontend/src/app/(teach)/review/[responseId]/page.tsx
@@ -6,6 +6,7 @@ import { serverApiFetchOrNotFound } from "@/lib/api-server";
import { submitReview } from "@/server/actions/reviews";
import { ReviewForm } from "@/components/teach/review-form";
import { QuizIntegrity } from "@/components/teach/evaluation/quiz-integrity";
+import { CapstoneReport, capstoneDraftFeedback } from "@/components/teach/evaluation/capstone-report";
export default async function ReviewDetailPage({
params,
@@ -21,6 +22,8 @@ export default async function ReviewDetailPage({
const { course, question, history } = response;
const alreadyReviewed = response.status !== "PENDING_REVIEW";
const reviewResponseId = response.id;
+ const capstone = response.capstone;
+ const capstonePercent = capstone?.current?.report?.percent ?? null;
async function submitReviewAction(formData: FormData) {
"use server";
@@ -63,8 +66,9 @@ export default async function ReviewDetailPage({
- {/* Left: the question, rubric, and reference answer — mentor-only config */}
-
+ {capstone ? : null}
+ {/* Left: the question, rubric, and reference answer, mentor-only config */}
+ {capstone ? null :
+ ),
+ { ...size, fonts: fonts.length ? fonts : undefined }
+ );
+}
diff --git a/frontend/src/app/verify/[code]/page.tsx b/frontend/src/app/verify/[code]/page.tsx
new file mode 100644
index 000000000..f7f5398f6
--- /dev/null
+++ b/frontend/src/app/verify/[code]/page.tsx
@@ -0,0 +1,147 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { CircleAlert, CircleCheck } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+import { SITE_NAME } from "@/lib/site";
+import { issuedOn, issuerLine, loadCertificate } from "./certificate-data";
+
+// Public certificate page: anyone with the link can confirm a certificate is real.
+// The artwork matches the share image (opengraph-image.tsx) so a LinkedIn post and the
+// page it opens look like the same document.
+
+type Props = { params: Promise<{ code: string }> };
+
+export async function generateMetadata({ params }: Props): Promise {
+ const { code } = await params;
+ const certificate = await loadCertificate(code);
+ if (!certificate) return { title: "Certificate not found", robots: { index: false } };
+ const title = `${certificate.recipientName}: ${certificate.title}`;
+ const description = `${certificate.organization} certificate ${certificate.code}, issued ${issuedOn(certificate.issuedAt)}.`;
+ return {
+ title,
+ description,
+ // Verify pages are for people who have the link, not for search results.
+ robots: { index: false, follow: false },
+ openGraph: { title, description, type: "website", siteName: SITE_NAME, url: certificate.verifyUrl },
+ twitter: { card: "summary_large_image", title, description },
+ };
+}
+
+function WeekBars({ size = 8 }: { size?: number }) {
+ const weekday = Math.round(size * 0.58);
+ return (
+
+ {[0, 1, 2, 3, 4, 5, 6].map((day) => (
+
+ ))}
+
+ );
+}
+
+export default async function VerifyCertificatePage({ params }: Props) {
+ const { code } = await params;
+ const certificate = await loadCertificate(code);
+ if (!certificate) notFound();
+ const revoked = certificate.revokedAt !== null;
+
+ return (
+
+
+
+ {SITE_NAME}
+ Certificate check
+
+
+
+ {revoked ? : }
+
+ {revoked
+ ? `This certificate was withdrawn on ${issuedOn(certificate.revokedAt!)}.${certificate.revokedReason ? ` Reason: ${certificate.revokedReason}` : ""}`
+ : `This certificate is genuine. ${certificate.organization} issued it to ${certificate.recipientName} on ${issuedOn(certificate.issuedAt)}.`}
+
+
+
+ {/* The document itself. White, one hairline border, square corners, ink type. */}
+
+
+ {check.reused ? "reused the grade for this commit" : (check.model ?? "no model call")}
+ {check.reused ? null : `, ${check.inputTokens.toLocaleString()} in / ${check.outputTokens.toLocaleString()} out, ${money(check.costUsd)}`}
+
+ ) : null}
+
+ );
+}
+
+/** Feedback text the mentor can send as it is (confirm) or edit (override). */
+export function capstoneDraftFeedback(check: CapstoneMentorView | null): string {
+ const report = check?.report;
+ if (!report) return "";
+ const left = report.whatIsLeft.map((item) => `- ${item.title}: ${item.steps[0] ?? ""}`).join("\n");
+ return [
+ `Confirmed the AI check: ${report.percent}%, ${report.completed ? "complete" : "not complete yet"}.`,
+ report.summary,
+ left ? `Still to do:\n${left}` : "",
+ ]
+ .filter(Boolean)
+ .join("\n\n");
+}
diff --git a/frontend/src/components/teach/mdx-editor-core.tsx b/frontend/src/components/teach/mdx-editor-core.tsx
index 5a1182d64..2a8e1fc90 100644
--- a/frontend/src/components/teach/mdx-editor-core.tsx
+++ b/frontend/src/components/teach/mdx-editor-core.tsx
@@ -37,6 +37,7 @@ import { normalizeLearningLogQuestionProps } from "@/shared/lib/learning-log";
import { quizEditorDescriptor } from "@/blocks/quiz/editorDescriptor";
import { predictEditorDescriptor } from "@/blocks/predict/editorDescriptor";
import { aiInterviewEditorDescriptor } from "@/blocks/ai-interview/editorDescriptor";
+import { capstoneCheckEditorDescriptor } from "@/blocks/capstone-check/editorDescriptor";
import { openQuestionEditorDescriptor } from "@/blocks/open-question/editorDescriptor";
import { codeEditorDescriptor } from "@/blocks/code/editorDescriptor";
import { mustReadEditorDescriptor } from "@/blocks/must-read/editorDescriptor";
@@ -58,6 +59,7 @@ const blockRegistryDescriptors = [
quizEditorDescriptor,
predictEditorDescriptor,
aiInterviewEditorDescriptor,
+ capstoneCheckEditorDescriptor,
openQuestionEditorDescriptor,
codeEditorDescriptor,
mustReadEditorDescriptor,
diff --git a/frontend/src/components/teach/review-form.tsx b/frontend/src/components/teach/review-form.tsx
index 4fe065ab8..904d858ce 100644
--- a/frontend/src/components/teach/review-form.tsx
+++ b/frontend/src/components/teach/review-form.tsx
@@ -13,15 +13,22 @@ type ReviewFormProps = {
responseId: string;
maxScore: number;
action: (formData: FormData) => Promise;
+ /** Prefill, for example from a capstone AI check the mentor confirms or overrides. */
+ initialFeedback?: string;
+ initialScore?: number | null;
+ /** The OpenRouter draft only applies to open questions. */
+ aiDraft?: boolean;
+ /** Replaces the default line under the heading. */
+ note?: string;
};
function verdictLabel(verdict: AiReviewDraft["suggestedVerdict"]) {
return verdict === "APPROVED" ? "Approve" : "Needs revision";
}
-export function ReviewForm({ responseId, maxScore, action }: ReviewFormProps) {
- const [feedback, setFeedback] = useState("");
- const [score, setScore] = useState("");
+export function ReviewForm({ responseId, maxScore, action, initialFeedback = "", initialScore = null, aiDraft = true, note }: ReviewFormProps) {
+ const [feedback, setFeedback] = useState(initialFeedback);
+ const [score, setScore] = useState(initialScore === null ? "" : String(initialScore));
const [draft, setDraft] = useState(null);
const [model, setModel] = useState(null);
const [error, setError] = useState(null);
@@ -49,10 +56,10 @@ export function ReviewForm({ responseId, maxScore, action }: ReviewFormProps) {
Your review
- You make the final decision. Drafted feedback is never submitted automatically.
+ {note ?? "You make the final decision. Drafted feedback is never submitted automatically."}
-
+ : null}
-
+ {aiDraft ?
When you click Draft with AI, this
answer, its question, the mentor-only rubric/reference answer, and recent feedback are sent
to OpenRouter. Verify the result before using it.
-
+
: null}
{error ? (
diff --git a/frontend/src/mdx/components.tsx b/frontend/src/mdx/components.tsx
index a8fdb432f..c1bb3d136 100644
--- a/frontend/src/mdx/components.tsx
+++ b/frontend/src/mdx/components.tsx
@@ -2,6 +2,7 @@ import type { MDXComponents } from "next-mdx-remote-client/rsc";
import { QuizComponent } from "@/blocks/quiz/Component";
import { PredictComponent } from "@/blocks/predict/Component";
import { AiInterviewComponent } from "@/blocks/ai-interview/Component";
+import { CapstoneCheckComponent } from "@/blocks/capstone-check/Component";
import { OpenQuestionComponent } from "@/blocks/open-question/Component";
import { CodeExerciseComponent } from "@/blocks/code/Component";
import { StepsComponent } from "@/blocks/steps/Component";
@@ -36,6 +37,7 @@ export const mdxComponents: MDXComponents = {
Quiz: QuizComponent,
Predict: PredictComponent,
AiInterview: AiInterviewComponent,
+ CapstoneCheck: CapstoneCheckComponent,
OpenQuestion: OpenQuestionComponent,
CodeExercise: CodeExerciseComponent,
Steps: StepsComponent,
diff --git a/frontend/src/shared/api/blocks.ts b/frontend/src/shared/api/blocks.ts
index d4555afff..091348c4a 100644
--- a/frontend/src/shared/api/blocks.ts
+++ b/frontend/src/shared/api/blocks.ts
@@ -15,6 +15,8 @@ import type { VisualDiagramConfig } from "../blocks/visual-diagram/schema";
import type { VisualWalkthroughConfig } from "../blocks/visual-walkthrough/schema";
import type { SanitizedAiInterviewConfig } from "../blocks/ai-interview/schema";
import type { AiInterviewInitialState } from "./ai";
+import type { SanitizedCapstoneCheckConfig } from "../blocks/capstone-check/schema";
+import type { CapstoneCheckState } from "../blocks/capstone-check/report";
// Wire contract for GET /api/v1/blocks/:blockId/state: the client-safe config plus
// the caller's latest answer, shaped for each block's client component.
@@ -75,6 +77,13 @@ export type AiInterviewBlockState = {
initialState: AiInterviewInitialState;
};
+export type CapstoneCheckBlockState = {
+ type: "CAPSTONE_CHECK";
+ config: SanitizedCapstoneCheckConfig;
+ /** Null for a signed-out reader. */
+ initialState: CapstoneCheckState | null;
+};
+
/** Presentation blocks carry no answer; the reader only needs their config. */
export type ContentBlockState =
| { type: "STEPS"; config: StepsConfig }
@@ -93,6 +102,7 @@ export type BlockState =
| OpenQuestionBlockState
| MustReadBlockState
| AiInterviewBlockState
+ | CapstoneCheckBlockState
| ContentBlockState;
/** Quiz-mode chapter: every question's last answer, for the wizard's initial state. */
diff --git a/frontend/src/shared/api/teach.ts b/frontend/src/shared/api/teach.ts
index f2d86e3d2..ed816d7f9 100644
--- a/frontend/src/shared/api/teach.ts
+++ b/frontend/src/shared/api/teach.ts
@@ -1,4 +1,5 @@
// GENERATED from server/src/shared — do not edit here. Run `pnpm sync-shared` in server/.
+import type { CapstoneMentorView } from "../blocks/capstone-check/report";
import type {
ChapterReaderMode,
CourseCategory,
@@ -557,6 +558,8 @@ export type ReviewDetailView = {
history: Array<{ id: string; attempt: number; verdict: ReviewVerdict; feedback: string; reviewerName: string | null }>;
/** Quiz integrity attempts on this chapter by this learner, newest first. */
integrity: IntegrityAttemptSummary[];
+ /** Capstone checks: the check under review and the learner's other checks on this block, newest first. */
+ capstone: { title: string; current: CapstoneMentorView | null; history: CapstoneMentorView[] } | null;
};
export type AiReviewDraft = {
diff --git a/frontend/src/shared/blocks/capstone-check/grade.ts b/frontend/src/shared/blocks/capstone-check/grade.ts
new file mode 100644
index 000000000..57426f3e8
--- /dev/null
+++ b/frontend/src/shared/blocks/capstone-check/grade.ts
@@ -0,0 +1,9 @@
+// GENERATED from server/src/shared — do not edit here. Run `pnpm sync-shared` in server/.
+import type { GradeResult } from "../types";
+import type { CapstoneCheckConfig, CapstoneCheckPayload } from "./schema";
+
+// The AI result is advisory. The Response exists once a finished check is sent to the
+// mentor (services/capstone/service.ts), so it always waits for a human verdict.
+export function gradeCapstoneCheck(_config: CapstoneCheckConfig, payload: CapstoneCheckPayload): GradeResult {
+ return { score: payload.percent, maxScore: 100, isCorrect: null, status: "PENDING_REVIEW" };
+}
diff --git a/frontend/src/shared/blocks/capstone-check/report.ts b/frontend/src/shared/blocks/capstone-check/report.ts
new file mode 100644
index 000000000..f56e75ebb
--- /dev/null
+++ b/frontend/src/shared/blocks/capstone-check/report.ts
@@ -0,0 +1,122 @@
+// GENERATED from server/src/shared — do not edit here. Run `pnpm sync-shared` in server/.
+import type { AutomatedCheckKind, CapstoneCheckConfig } from "./schema";
+
+// Wire and storage shapes for a capstone check result. The learner and the mentor
+// read the same report; the mentor view adds the cost, the model and the commit.
+
+export type AutomatedCheckStatus = "pass" | "fail" | "pending" | "skipped";
+
+export type AutomatedCheckResult = {
+ id: string;
+ check: AutomatedCheckKind;
+ status: AutomatedCheckStatus;
+ /** One plain sentence the learner can act on. */
+ message: string;
+ /** True when this check failing stops the run before the AI grade. Missing on older rows. */
+ blocking?: boolean;
+};
+
+/** Older stored results have no `blocking`; fall back to the default for their kind. */
+export function blocksGrade(result: AutomatedCheckResult): boolean {
+ const blocking = result.blocking ?? (result.check === "repo_public" || result.check === "ci_green" || result.check === "files_match");
+ return blocking && (result.status === "fail" || result.status === "pending");
+}
+
+export type CriterionResult = {
+ id: string;
+ title: string;
+ required: boolean;
+ weight: number;
+ score: number;
+ max: number;
+ met: boolean;
+ /** Set when an automated gate forced the score to 0. */
+ gatedBy: string[];
+ evidence: string[];
+ missing: string[];
+};
+
+export type WhatIsLeftItem = {
+ criterionId: string | null;
+ title: string;
+ steps: string[];
+};
+
+export type CapstoneReport = {
+ completed: boolean;
+ percent: number;
+ passPercent: number;
+ /** Two or three sentences for the top of the result card. */
+ summary: string;
+ criteria: CriterionResult[];
+ whatIsLeft: WhatIsLeftItem[];
+ qualityNotes: { strengths: string[]; improvements: string[] };
+ confidence: "high" | "medium" | "low";
+ /**
+ * Integrity concerns the grader raised (text aimed at the grader, copied work, weakened
+ * tests). Mentor only: stripped from the learner's view. Any flag sends the check to the mentor.
+ */
+ integrityFlags?: string[];
+ /** False when a hard gate stopped the run before the AI grade. */
+ graded: boolean;
+};
+
+export type CapstoneCheckStatusName = "QUEUED" | "RUNNING" | "DONE" | "FAILED";
+
+export type CapstoneCheckView = {
+ id: string;
+ attempt: number;
+ status: CapstoneCheckStatusName;
+ repoUrl: string;
+ liveUrl: string | null;
+ commitSha: string | null;
+ createdAt: string;
+ finishedAt: string | null;
+ automated: AutomatedCheckResult[];
+ report: CapstoneReport | null;
+ error: string | null;
+ /** True once this check is in the mentor's queue. */
+ sentToMentor: boolean;
+ reused: boolean;
+};
+
+export type CapstoneMentorDecision = {
+ verdict: "APPROVED" | "NEEDS_REVISION";
+ feedback: string;
+ score: number | null;
+ reviewerName: string | null;
+ createdAt: string;
+};
+
+export type CapstoneCheckState = {
+ /** Newest first. */
+ checks: CapstoneCheckView[];
+ remainingToday: number;
+ dailyLimit: number;
+ available: boolean;
+ /** The mentor's decision on the latest check they reviewed, if any. */
+ decision: CapstoneMentorDecision | null;
+};
+
+export type CapstoneMentorView = CapstoneCheckView & {
+ model: string | null;
+ inputTokens: number;
+ outputTokens: number;
+ costUsd: number;
+ packId: string;
+ packVersion: number;
+};
+
+/** Report projector for docs/06-reports.mdx: no rubric text. */
+export function reportCapstoneCheck(config: CapstoneCheckConfig) {
+ return {
+ type: "CAPSTONE_CHECK",
+ title: config.title ?? config.rubric.title,
+ summary: "",
+ details: [] as unknown[],
+ metric: "review" as const,
+ pack: config.rubric.pack,
+ packVersion: config.rubric.version,
+ passPercent: config.rubric.passPercent,
+ };
+}
diff --git a/frontend/src/shared/blocks/capstone-check/schema.ts b/frontend/src/shared/blocks/capstone-check/schema.ts
new file mode 100644
index 000000000..c59f67191
--- /dev/null
+++ b/frontend/src/shared/blocks/capstone-check/schema.ts
@@ -0,0 +1,242 @@
+// GENERATED from server/src/shared — do not edit here. Run `pnpm sync-shared` in server/.
+import { z } from "zod";
+import { isSafeSubmissionUrl } from "../../lib/safe-submission-url";
+
+// CAPSTONE_CHECK: the learner submits a GitHub repo (and a live URL when the pack asks),
+// the server runs automated checks, then grades against the rubric pack with the
+// platform's Anthropic key. See docs/16-capstone-checker-and-certificates.mdx.
+//
+// The rubric pack is authored as capstone/.yaml in the course package and
+// inlined into Block.config.rubric by the importer. `rubric` is a SECRET_KEY, so the
+// pack never reaches the browser; the reader gets the projection built in
+// services/blocks/state.ts (title, live URL rule, criterion titles).
+
+const kebab = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use kebab-case");
+/** Check ids default to the check name (live_ok), so underscores are allowed here. */
+const checkId = z.string().regex(/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/, "Use lower-case words joined by - or _");
+
+export const AUTOMATED_CHECKS = [
+ "repo_public",
+ "ci_green",
+ "tag",
+ "files",
+ "files_match",
+ "file_contains",
+ "check_run",
+ "live_ok",
+ "live_contains",
+] as const;
+export type AutomatedCheckKind = (typeof AUTOMATED_CHECKS)[number];
+
+/**
+ * Checks that stop the run before the model is called by default: nothing to grade, the build
+ * is broken, or a course-owned file was changed. A spec can override this with `stop`.
+ */
+export const HARD_STOP_CHECKS: ReadonlySet = new Set(["repo_public", "ci_green", "files_match"]);
+
+const sitePath = z.string().regex(/^\/[^\s]*$/, "Start the path with /").max(200);
+const repoPath = z.string().min(1).max(200).regex(/^(?!\/)(?!.*\.\.)[^\s*?]+$/, "Use a plain repo path like .github/workflows/ci.yml");
+/** A template repo at a tag or commit: https://github.com/owner/repo@v1.0.0 */
+export const TEMPLATE_REF = /^https:\/\/github\.com\/[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}@[A-Za-z0-9._/-]{1,100}$/;
+const stop = z.boolean().optional();
+
+const AutomatedCheckSpecSchema = z.discriminatedUnion("check", [
+ z.object({ check: z.literal("repo_public"), id: checkId.optional() }).strict(),
+ z.object({ check: z.literal("ci_green"), id: checkId.optional(), stop }).strict(),
+ z.object({ check: z.literal("tag"), id: checkId.optional(), pattern: z.string().min(1).max(80), stop }).strict(),
+ z.object({ check: z.literal("files"), id: checkId.optional(), paths: z.array(z.string().min(1).max(200)).min(1).max(30), stop }).strict(),
+ /** Course-owned files (workflow, supplied tests) must be byte-identical to the template at a tag. */
+ z.object({ check: z.literal("files_match"), id: checkId.optional(), paths: z.array(repoPath).min(1).max(20), against: z.string().regex(TEMPLATE_REF, "Use https://github.com/owner/repo@"), stop }).strict(),
+ /** A repo file contains a line, for example a pinned `uses:` of a reusable workflow. */
+ z.object({ check: z.literal("file_contains"), id: checkId.optional(), path: repoPath, text: z.string().min(1).max(300), stop }).strict(),
+ /** One named GitHub check run on the commit concluded with success. */
+ z.object({ check: z.literal("check_run"), id: checkId.optional(), name: z.string().trim().min(1).max(100), stop }).strict(),
+ z.object({ check: z.literal("live_ok"), id: checkId.optional(), path: sitePath.optional(), stop }).strict(),
+ z.object({ check: z.literal("live_contains"), id: checkId.optional(), text: z.string().min(1).max(200), path: sitePath.optional(), stop }).strict(),
+]);
+
+export type AutomatedCheckSpec = z.infer & { id: string };
+
+/** Whether a failed or pending check stops the run before the AI grade. */
+export function stopsRun(spec: { check: AutomatedCheckKind; stop?: boolean }): boolean {
+ return spec.check === "repo_public" || (spec.stop ?? HARD_STOP_CHECKS.has(spec.check));
+}
+
+const CriterionSchema = z
+ .object({
+ id: kebab,
+ title: z.string().trim().min(1).max(120),
+ weight: z.number().int().min(1).max(100),
+ required: z.boolean().default(false),
+ /** Automated check ids; the criterion scores 0 if any of them did not pass. */
+ gate: z.array(checkId).max(6).default([]),
+ /** What the grader should look for, in one or two sentences. */
+ lookFor: z.string().trim().min(1).max(1200),
+ /** Score levels: "0" is required, the highest key is full marks. */
+ levels: z.record(z.string().regex(/^\d$/, "Levels are 0 to 9"), z.string().trim().min(1).max(600)),
+ /** Lowest level that counts as met; defaults to full marks. */
+ passLevel: z.number().int().min(0).max(9).optional(),
+ })
+ .strict()
+ .superRefine((criterion, ctx) => {
+ const levels = Object.keys(criterion.levels).map(Number);
+ if (!levels.includes(0)) ctx.addIssue({ code: "custom", path: ["levels"], message: "Describe level 0" });
+ const max = Math.max(...levels);
+ if (max < 1) ctx.addIssue({ code: "custom", path: ["levels"], message: "Describe at least one level above 0" });
+ if (criterion.passLevel !== undefined && criterion.passLevel > max) {
+ ctx.addIssue({ code: "custom", path: ["passLevel"], message: "passLevel is above the highest level" });
+ }
+ });
+
+export const RubricPackSchema = z
+ .object({
+ pack: kebab.max(80),
+ version: z.number().int().min(1),
+ title: z.string().trim().min(1).max(160),
+ passPercent: z.number().int().min(0).max(100).default(70),
+ submission: z
+ .object({
+ repo: z.literal("required").default("required"),
+ liveUrl: z.enum(["required", "optional", "none"]).default("none"),
+ })
+ .strict()
+ .default({ repo: "required", liveUrl: "none" }),
+ automated: z.array(AutomatedCheckSpecSchema).max(20).default([{ check: "repo_public" }]),
+ snapshot: z
+ .object({
+ include: z.array(z.string().min(1).max(200)).max(40).default(["README.md", "**/*"]),
+ exclude: z.array(z.string().min(1).max(200)).max(40).default([]),
+ maxFiles: z.number().int().min(1).max(200).default(60),
+ maxBytesPerFile: z.number().int().min(1000).max(200_000).default(40_000),
+ })
+ .strict()
+ .default({ include: ["README.md", "**/*"], exclude: [], maxFiles: 60, maxBytesPerFile: 40_000 }),
+ /** Extra instructions for the grader, for example the stack the template uses. */
+ guidance: z.string().trim().max(4000).optional(),
+ criteria: z.array(CriterionSchema).min(1).max(20),
+ /** Level 3 hook: a passed oral exam stage before a mentor can confirm. Not built yet. */
+ oralExam: z.object({ enabled: z.boolean().default(false) }).strict().default({ enabled: false }),
+ })
+ .strict()
+ .superRefine((pack, ctx) => {
+ const ids = new Set();
+ pack.automated.forEach((spec, index) => {
+ const id = spec.id ?? spec.check;
+ if (ids.has(id)) ctx.addIssue({ code: "custom", path: ["automated", index], message: `Duplicate check id "${id}"; give each one an id` });
+ ids.add(id);
+ if ((spec.check === "live_ok" || spec.check === "live_contains") && pack.submission.liveUrl === "none") {
+ ctx.addIssue({ code: "custom", path: ["automated", index], message: "Live checks need submission.liveUrl" });
+ }
+ });
+ if (!pack.automated.some((spec) => spec.check === "repo_public")) {
+ ctx.addIssue({ code: "custom", path: ["automated"], message: "Include a repo_public check" });
+ }
+ const criterionIds = new Set();
+ pack.criteria.forEach((criterion, index) => {
+ if (criterionIds.has(criterion.id)) ctx.addIssue({ code: "custom", path: ["criteria", index, "id"], message: "Duplicate criterion id" });
+ criterionIds.add(criterion.id);
+ for (const gate of criterion.gate) {
+ if (!ids.has(gate)) ctx.addIssue({ code: "custom", path: ["criteria", index, "gate"], message: `Unknown check id "${gate}"` });
+ }
+ });
+ if (pack.oralExam.enabled) {
+ ctx.addIssue({ code: "custom", path: ["oralExam"], message: "The oral exam stage is not available yet; set enabled: false" });
+ }
+ });
+
+export type RubricPack = z.infer;
+export type RubricCriterion = RubricPack["criteria"][number];
+
+/** Every automated spec with its effective id (the check name unless the pack gives one). */
+export function automatedSpecs(pack: RubricPack): AutomatedCheckSpec[] {
+ return pack.automated.map((spec) => ({ ...spec, id: spec.id ?? spec.check }));
+}
+
+export function maxLevel(criterion: RubricCriterion): number {
+ return Math.max(...Object.keys(criterion.levels).map(Number));
+}
+
+export function passLevel(criterion: RubricCriterion): number {
+ return criterion.passLevel ?? maxLevel(criterion);
+}
+
+export const CapstoneCheckConfigSchema = z.object({
+ title: z.string().trim().min(1).max(160).optional(),
+ /** Short learner-facing line above the form. */
+ intro: z.string().trim().max(600).optional(),
+ /** Path of the pack inside the course package, as authored. */
+ pack: z.string().min(1).max(200),
+ /** The parsed pack, inlined by the importer. Server-only. */
+ rubric: RubricPackSchema,
+});
+
+export type CapstoneCheckConfig = z.infer;
+
+/** What the reader receives: never the rubric levels, weights or grader guidance. */
+export type SanitizedCapstoneCheckConfig = {
+ title: string;
+ intro: string | null;
+ liveUrl: "required" | "optional" | "none";
+ checklist: Array<{ id: string; title: string; required: boolean }>;
+};
+
+export function capstoneClientConfig(config: CapstoneCheckConfig): SanitizedCapstoneCheckConfig {
+ return {
+ title: config.title ?? config.rubric.title,
+ intro: config.intro ?? null,
+ liveUrl: config.rubric.submission.liveUrl,
+ checklist: config.rubric.criteria.map((criterion) => ({ id: criterion.id, title: criterion.title, required: criterion.required })),
+ };
+}
+
+const GITHUB_REPO_URL = /^https:\/\/github\.com\/[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}\/?$/;
+
+export const CapstoneSubmitSchema = z.object({
+ repoUrl: z
+ .string()
+ .trim()
+ .transform((value) => value.replace(/\.git$/, "").replace(/\/$/, ""))
+ .refine((value) => GITHUB_REPO_URL.test(value), "Paste the repo link, for example https://github.com/you/your-project"),
+ liveUrl: z
+ .string()
+ .trim()
+ .max(500)
+ .optional()
+ .transform((value) => (value ? value : undefined))
+ .refine((value) => value === undefined || (isSafeSubmissionUrl(value) && value.startsWith("https://")), "Use a secure https:// link"),
+});
+
+export type CapstoneSubmit = z.infer;
+
+/** Stored on the Response a mentor reviews. */
+export type CapstoneCheckPayload = {
+ checkId: string;
+ repoUrl: string;
+ liveUrl: string | null;
+ commitSha: string | null;
+ percent: number;
+ completed: boolean;
+};
+
+/** Path rule for a rubric pack inside a course package. */
+export const CAPSTONE_PACK_PATH = /^capstone\/[a-z0-9]+(?:-[a-z0-9]+)*\.yaml$/;
+
+/**
+ * The ```capstone fence an author writes in a chapter:
+ *
+ * ```capstone
+ * title: Ship your personal site
+ * intro: Paste your repo and your live site. The check takes about a minute.
+ * pack: capstone/rubric.yaml
+ * ```
+ *
+ * The importer reads the pack file and stores it in Block.config.rubric.
+ */
+export const CapstoneFenceSchema = z
+ .object({
+ title: z.string().trim().min(1).max(160).optional(),
+ intro: z.string().trim().max(600).optional(),
+ pack: z.string().regex(CAPSTONE_PACK_PATH, "Point pack at a file like capstone/rubric.yaml"),
+ required: z.boolean().optional(),
+ })
+ .strict();
diff --git a/frontend/src/shared/blocks/definitions.ts b/frontend/src/shared/blocks/definitions.ts
index ac426e88c..8a969a8dc 100644
--- a/frontend/src/shared/blocks/definitions.ts
+++ b/frontend/src/shared/blocks/definitions.ts
@@ -31,6 +31,9 @@ import { RoadmapConfigSchema, type RoadmapConfig } from "./roadmap/schema";
import { AiInterviewConfigSchema, type AiInterviewConfig, type AiInterviewPayload } from "./ai-interview/schema";
import { gradeAiInterview } from "./ai-interview/grade";
import { reportAiInterview } from "./ai-interview/report";
+import { CapstoneCheckConfigSchema, type CapstoneCheckConfig, type CapstoneCheckPayload } from "./capstone-check/schema";
+import { gradeCapstoneCheck } from "./capstone-check/grade";
+import { reportCapstoneCheck } from "./capstone-check/report";
// The one place a BlockType maps to its schema, grader and report — see
// docs/03-blocks-registry.mdx. The API grades against this table; the frontend
@@ -163,6 +166,15 @@ export const blockDefinitions = {
grade: gradeAiInterview,
report: reportAiInterview,
} satisfies BlockDefinition,
+ CAPSTONE_CHECK: {
+ type: "CAPSTONE_CHECK",
+ version: 1,
+ behavior: "assessment",
+ progress: "review",
+ schema: CapstoneCheckConfigSchema,
+ grade: gradeCapstoneCheck,
+ report: reportCapstoneCheck,
+ } satisfies BlockDefinition,
} as const;
export type RegisteredBlockType = keyof typeof blockDefinitions;
diff --git a/frontend/src/shared/domain/enums.ts b/frontend/src/shared/domain/enums.ts
index f5fc1fc28..611df2481 100644
--- a/frontend/src/shared/domain/enums.ts
+++ b/frontend/src/shared/domain/enums.ts
@@ -22,6 +22,7 @@ export const BLOCK_TYPES = [
"VISUAL_DIAGRAM",
"ROADMAP",
"AI_INTERVIEW",
+ "CAPSTONE_CHECK",
] as const;
export type BlockType = (typeof BLOCK_TYPES)[number];
diff --git a/frontend/src/shared/lib/certificates.ts b/frontend/src/shared/lib/certificates.ts
new file mode 100644
index 000000000..e1263e032
--- /dev/null
+++ b/frontend/src/shared/lib/certificates.ts
@@ -0,0 +1,110 @@
+// GENERATED from server/src/shared — do not edit here. Run `pnpm sync-shared` in server/.
+import { z } from "zod";
+
+// Completion certificates (docs/16-capstone-checker-and-certificates.mdx). A course opts in
+// with a `certificate` block in course.yaml; the API issues one per passed enrollment and
+// the public page /verify/ shows it to anyone.
+
+export const CertificateSettingsSchema = z
+ .object({
+ /** The programme line, for example "Dev Weekends Mentorship". */
+ program: z.string().trim().min(1).max(120).default("Dev Weekends Mentorship"),
+ /** The track title on the certificate and in LinkedIn's Name field. */
+ title: z.string().trim().min(1).max(120),
+ /** Short track code used in the public id, for example L2 in DW-L2-7K3F9QXM. */
+ code: z.string().regex(/^[A-Z0-9]{1,6}$/, "Use 1 to 6 capital letters or digits"),
+ /** The programme's organisation, shown on the certificate. LinkedIn's issuer is always Edorax. */
+ organization: z.string().trim().min(1).max(120).default("Dev Weekends"),
+ /** Prefix of the public id. */
+ prefix: z.string().regex(/^[A-Z]{2,4}$/).default("DW"),
+ /**
+ * Who signs the certificate. Leave the name out to use the course's trainer, the instructor
+ * name the catalog shows for the course.
+ */
+ issuer: z
+ .object({
+ name: z.string().trim().min(1).max(120).optional(),
+ title: z.string().trim().min(1).max(120).optional(),
+ })
+ .strict()
+ .default({}),
+ skills: z.array(z.string().trim().min(1).max(60)).max(12).default([]),
+ })
+ .strict();
+
+export type CertificateSettings = z.infer;
+
+export type CertificateEvidence = {
+ chapterTitle: string;
+ repoUrl: string;
+ liveUrl: string | null;
+ commitSha: string | null;
+ percent: number | null;
+};
+
+export type CertificateView = {
+ code: string;
+ recipientName: string;
+ title: string;
+ program: string;
+ organization: string;
+ issuerName: string;
+ issuerTitle: string | null;
+ skills: string[];
+ issuedAt: string;
+ revokedAt: string | null;
+ revokedReason: string | null;
+ evidence: CertificateEvidence[];
+ verifyUrl: string;
+ imageUrl: string;
+ linkedInAddUrl: string;
+ linkedInShareUrl: string;
+};
+
+/** The organisation LinkedIn lists as the issuer: the platform that issues and verifies certificates. */
+export const LINKEDIN_ISSUER_NAME = "Edorax";
+
+export type CertificateEligibility = {
+ /** False when the course has no certificate block. */
+ offered: boolean;
+ eligible: boolean;
+ /** What is still needed, in plain words. */
+ missing: string[];
+};
+
+export type MyCertificateState = {
+ eligibility: CertificateEligibility;
+ certificate: CertificateView | null;
+ suggestedName: string | null;
+};
+
+/** Crockford base32 without I, L, O, U: easy to read aloud and type. */
+export const CERTIFICATE_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
+export const CERTIFICATE_CODE_PATTERN = /^[A-Z]{2,4}-[A-Z0-9]{1,6}-[0-9A-HJKMNP-TV-Z]{8}$/;
+
+/**
+ * LinkedIn's Add to profile form, pre-filled as a Licence or certification.
+ * With an organisation id LinkedIn links the issuer page; without it the name is free text.
+ */
+export function linkedInAddToProfileUrl(input: {
+ name: string;
+ organizationId?: string | null;
+ organizationName: string;
+ issuedAt: Date;
+ certUrl: string;
+ certId: string;
+}): string {
+ const params = new URLSearchParams({ startTask: "CERTIFICATION_NAME", name: input.name });
+ if (input.organizationId) params.set("organizationId", input.organizationId);
+ else params.set("organizationName", input.organizationName);
+ params.set("issueYear", String(input.issuedAt.getUTCFullYear()));
+ params.set("issueMonth", String(input.issuedAt.getUTCMonth() + 1));
+ params.set("certUrl", input.certUrl);
+ params.set("certId", input.certId);
+ return `https://www.linkedin.com/profile/add?${params.toString()}`;
+}
+
+/** LinkedIn's share dialog for a post with the verify link; the page's Open Graph image shows. */
+export function linkedInShareUrl(url: string): string {
+ return `https://www.linkedin.com/sharing/share-offsite/?${new URLSearchParams({ url }).toString()}`;
+}
diff --git a/frontend/src/shared/mdx/component-allowlist.ts b/frontend/src/shared/mdx/component-allowlist.ts
index 87d5ecfd0..f7320b7b3 100644
--- a/frontend/src/shared/mdx/component-allowlist.ts
+++ b/frontend/src/shared/mdx/component-allowlist.ts
@@ -7,6 +7,7 @@ export const MDX_COMPONENT_NAMES = [
"Quiz",
"Predict",
"AiInterview",
+ "CapstoneCheck",
"OpenQuestion",
"CodeExercise",
"Steps",
diff --git a/frontend/src/shared/mdx/extract-block-config.ts b/frontend/src/shared/mdx/extract-block-config.ts
index 7e5321b7c..3cc9afe95 100644
--- a/frontend/src/shared/mdx/extract-block-config.ts
+++ b/frontend/src/shared/mdx/extract-block-config.ts
@@ -100,7 +100,7 @@ export function extractBlockConfigsFromSource(source: string): Map {
const element = node as unknown as MdxJsxElement;
- if (!["Quiz", "Predict", "AiInterview", "OpenQuestion", "CodeExercise", "MustRead", "Steps", "ProjectPreview", "LearningObjectives", "ChapterRecap", "VideoBlock", "VisualWalkthrough", "VisualDiagram", "Roadmap"].includes(element.name ?? "")) return;
+ if (!["Quiz", "Predict", "AiInterview", "CapstoneCheck", "OpenQuestion", "CodeExercise", "MustRead", "Steps", "ProjectPreview", "LearningObjectives", "ChapterRecap", "VideoBlock", "VisualWalkthrough", "VisualDiagram", "Roadmap"].includes(element.name ?? "")) return;
const props = readJsxProperties(element);
const id = props.id?.trim();
if (!id) return;
diff --git a/frontend/src/shared/mdx/extract.ts b/frontend/src/shared/mdx/extract.ts
index 886948481..3fcf37332 100644
--- a/frontend/src/shared/mdx/extract.ts
+++ b/frontend/src/shared/mdx/extract.ts
@@ -26,6 +26,7 @@ export const BLOCK_TAG_NAMES = {
CodeExercise: "CODE",
Predict: "PREDICT",
AiInterview: "AI_INTERVIEW",
+ CapstoneCheck: "CAPSTONE_CHECK",
Steps: "STEPS",
ProjectPreview: "PROJECT_PREVIEW",
LearningObjectives: "LEARNING_OBJECTIVES",
diff --git a/frontend/src/shared/mdx/restore-block-tags.ts b/frontend/src/shared/mdx/restore-block-tags.ts
index 70ebb765e..fd8b66329 100644
--- a/frontend/src/shared/mdx/restore-block-tags.ts
+++ b/frontend/src/shared/mdx/restore-block-tags.ts
@@ -20,6 +20,7 @@ export function restoreInteractiveBlockTags(source: string): string {
"LearningObjectives",
"Predict",
"AiInterview",
+ "CapstoneCheck",
"MustRead",
"MandatoryReadCard",
"VideoBlock",
diff --git a/packages/learning-ui/src/shared/domain/enums.ts b/packages/learning-ui/src/shared/domain/enums.ts
index f5fc1fc28..611df2481 100644
--- a/packages/learning-ui/src/shared/domain/enums.ts
+++ b/packages/learning-ui/src/shared/domain/enums.ts
@@ -22,6 +22,7 @@ export const BLOCK_TYPES = [
"VISUAL_DIAGRAM",
"ROADMAP",
"AI_INTERVIEW",
+ "CAPSTONE_CHECK",
] as const;
export type BlockType = (typeof BLOCK_TYPES)[number];
diff --git a/server/package.json b/server/package.json
index 6cf9f149a..2cc6af8dd 100644
--- a/server/package.json
+++ b/server/package.json
@@ -32,6 +32,7 @@
"courses:check": "tsx scripts/repository-course-publish.ts --check",
"courses:package:check": "tsx scripts/check-marketplace-package.ts",
"courses:publish": "tsx scripts/repository-course-publish.ts --publish",
+ "capstone:calibrate": "tsx scripts/capstone-calibrate.ts",
"content:strip-code": "tsx scripts/strip-code-exercises.ts",
"content:seed-predicts": "tsx scripts/seed-predict-fences.ts",
"stats:backfill": "tsx scripts/backfill-block-stats.ts",
diff --git a/server/prisma/migrations/20261106190000_capstone_checks_and_certificates/migration.sql b/server/prisma/migrations/20261106190000_capstone_checks_and_certificates/migration.sql
new file mode 100644
index 000000000..4d1705e71
--- /dev/null
+++ b/server/prisma/migrations/20261106190000_capstone_checks_and_certificates/migration.sql
@@ -0,0 +1,105 @@
+-- Capstone checker and certificates (docs/16-capstone-checker-and-certificates.mdx).
+-- CreateEnum
+CREATE TYPE "CapstoneCheckStatus" AS ENUM ('QUEUED', 'RUNNING', 'DONE', 'FAILED');
+
+-- AlterEnum
+ALTER TYPE "BlockType" ADD VALUE 'CAPSTONE_CHECK';
+
+-- AlterTable
+ALTER TABLE "Course" ADD COLUMN "certificate" JSONB;
+
+-- CreateTable
+CREATE TABLE "CapstoneCheck" (
+ "id" TEXT NOT NULL,
+ "blockId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "enrollmentId" TEXT NOT NULL,
+ "responseId" TEXT,
+ "attempt" INTEGER NOT NULL,
+ "status" "CapstoneCheckStatus" NOT NULL DEFAULT 'QUEUED',
+ "repoUrl" TEXT NOT NULL,
+ "liveUrl" TEXT,
+ "commitSha" VARCHAR(64),
+ "packId" VARCHAR(80) NOT NULL,
+ "packVersion" INTEGER NOT NULL,
+ "automated" JSONB,
+ "report" JSONB,
+ "percent" INTEGER,
+ "completed" BOOLEAN,
+ "reusedFromId" TEXT,
+ "aiCalled" BOOLEAN NOT NULL DEFAULT false,
+ "model" VARCHAR(120),
+ "inputTokens" INTEGER NOT NULL DEFAULT 0,
+ "outputTokens" INTEGER NOT NULL DEFAULT 0,
+ "costMicroUsd" INTEGER NOT NULL DEFAULT 0,
+ "error" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "startedAt" TIMESTAMP(3),
+ "finishedAt" TIMESTAMP(3),
+
+ CONSTRAINT "CapstoneCheck_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Certificate" (
+ "id" TEXT NOT NULL,
+ "code" VARCHAR(32) NOT NULL,
+ "userId" TEXT NOT NULL,
+ "courseId" TEXT NOT NULL,
+ "enrollmentId" TEXT NOT NULL,
+ "recipientName" TEXT NOT NULL,
+ "program" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "issuerName" TEXT NOT NULL,
+ "issuerTitle" TEXT,
+ "skills" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "evidence" JSONB,
+ "issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "revokedAt" TIMESTAMP(3),
+ "revokedReason" TEXT,
+ "revokedById" TEXT,
+
+ CONSTRAINT "Certificate_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CapstoneCheck_responseId_key" ON "CapstoneCheck"("responseId");
+
+-- CreateIndex
+CREATE INDEX "CapstoneCheck_blockId_userId_createdAt_idx" ON "CapstoneCheck"("blockId", "userId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "CapstoneCheck_status_createdAt_idx" ON "CapstoneCheck"("status", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "CapstoneCheck_aiCalled_createdAt_idx" ON "CapstoneCheck"("aiCalled", "createdAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Certificate_code_key" ON "Certificate"("code");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Certificate_enrollmentId_key" ON "Certificate"("enrollmentId");
+
+-- CreateIndex
+CREATE INDEX "Certificate_userId_idx" ON "Certificate"("userId");
+
+-- CreateIndex
+CREATE INDEX "Certificate_courseId_idx" ON "Certificate"("courseId");
+
+-- AddForeignKey
+ALTER TABLE "CapstoneCheck" ADD CONSTRAINT "CapstoneCheck_blockId_fkey" FOREIGN KEY ("blockId") REFERENCES "Block"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CapstoneCheck" ADD CONSTRAINT "CapstoneCheck_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CapstoneCheck" ADD CONSTRAINT "CapstoneCheck_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "Response"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_enrollmentId_fkey" FOREIGN KEY ("enrollmentId") REFERENCES "Enrollment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 5eeee6521..bc20f5372 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -176,6 +176,7 @@ enum BlockType {
VISUAL_DIAGRAM
ROADMAP
AI_INTERVIEW
+ CAPSTONE_CHECK
}
enum ResponseStatus {
@@ -306,6 +307,8 @@ model User {
aiCredential AiCredential?
aiInterviewSessions AiInterviewSession[]
learnerQuestions LearnerQuestion[]
+ capstoneChecks CapstoneCheck[]
+ certificates Certificate[]
appActivity UserAppActivity?
appActivityDays AppActivityDay[]
notifications Notification[]
@@ -425,6 +428,8 @@ model Course {
familySlug String? // course family in src/shared/lib/course-tracks.ts; null = no variants
variantFamily Json? // creator-defined variant catalog; null uses a built-in family
assistant Json? // tutor settings from course.yaml (effort, guidance, starters); null means defaults
+ certificate Json? // certificate settings from course.yaml (program, title, code, skills); null means no certificate
+ certificates Certificate[]
availableVariantSlugs String[] @default([]) // empty means every family variant is available
variantAssignment VariantAssignment @default(SELF_SELECT)
activeReleaseId String? // latest approved content release; set atomically with publication
@@ -626,6 +631,7 @@ model Block {
responses Response[]
stats BlockStats?
aiInterviewSessions AiInterviewSession[]
+ capstoneChecks CapstoneCheck[]
@@index([chapterId, order])
}
@@ -673,6 +679,7 @@ model Enrollment {
instructorCouponRedemption InstructorCouponRedemption?
xpEntries XpEntry[]
couponUsage CouponUsage?
+ certificate Certificate?
@@unique([courseId, userId])
@@unique([courseId, learnerProfileId])
@@ -1191,6 +1198,7 @@ model Response {
startedAt DateTime?
submittedAt DateTime @default(now())
review Review?
+ capstoneCheck CapstoneCheck?
@@index([blockId, userId, attempt])
@@index([enrollmentId])
@@ -1420,3 +1428,81 @@ model LearnerQuestion {
@@index([createdAt])
@@index([userId, createdAt])
}
+
+enum CapstoneCheckStatus {
+ QUEUED
+ RUNNING
+ DONE
+ FAILED
+}
+
+/// One run of a CAPSTONE_CHECK block (docs/16-capstone-checker-and-certificates.mdx).
+/// Automated checks run first; the AI grade runs only when the hard gates pass.
+/// A DONE check creates the PENDING_REVIEW Response a mentor confirms or overrides.
+model CapstoneCheck {
+ id String @id @default(cuid())
+ blockId String
+ block Block @relation(fields: [blockId], references: [id], onDelete: Cascade)
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ enrollmentId String
+ responseId String? @unique
+ response Response? @relation(fields: [responseId], references: [id], onDelete: SetNull)
+ attempt Int
+ status CapstoneCheckStatus @default(QUEUED)
+ repoUrl String
+ liveUrl String?
+ commitSha String? @db.VarChar(64)
+ packId String @db.VarChar(80)
+ packVersion Int
+ /// AutomatedCheckResult[] in the order the pack lists them.
+ automated Json?
+ /// CapstoneReport: the recomputed grade, criteria, what is left and quality notes.
+ report Json?
+ percent Int?
+ completed Boolean?
+ /// Set when the same commit and pack version were already graded; no model call was made.
+ reusedFromId String?
+ /// True once the platform-wide daily model budget was reserved for this check.
+ aiCalled Boolean @default(false)
+ model String? @db.VarChar(120)
+ inputTokens Int @default(0)
+ outputTokens Int @default(0)
+ costMicroUsd Int @default(0)
+ error String? @db.Text
+ createdAt DateTime @default(now())
+ startedAt DateTime?
+ finishedAt DateTime?
+
+ @@index([blockId, userId, createdAt])
+ @@index([status, createdAt])
+ @@index([aiCalled, createdAt])
+}
+
+/// A completion certificate for a passed track. The code is the public id on /verify/.
+/// Names and titles are snapshots so a later course rename never changes an issued certificate.
+model Certificate {
+ id String @id @default(cuid())
+ code String @unique @db.VarChar(32)
+ userId String
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ courseId String
+ course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
+ enrollmentId String @unique
+ enrollment Enrollment @relation(fields: [enrollmentId], references: [id], onDelete: Cascade)
+ recipientName String
+ program String
+ title String
+ issuerName String
+ issuerTitle String?
+ skills String[] @default([])
+ /// [{ chapterTitle, repoUrl, liveUrl, commitSha, percent }] for the confirmed capstones.
+ evidence Json?
+ issuedAt DateTime @default(now())
+ revokedAt DateTime?
+ revokedReason String?
+ revokedById String?
+
+ @@index([userId])
+ @@index([courseId])
+}
diff --git a/server/scripts/capstone-calibrate.ts b/server/scripts/capstone-calibrate.ts
new file mode 100644
index 000000000..efe80df4f
--- /dev/null
+++ b/server/scripts/capstone-calibrate.ts
@@ -0,0 +1,72 @@
+// Calibration for a capstone rubric pack (docs/16-capstone-checker-and-certificates.mdx).
+//
+// pnpm capstone:calibrate --pack ../../Edorax-core/courses/dw-level-2/capstone/rubric.yaml
+// Replays each example's recorded grader output. Free; this is what CI runs.
+//
+// pnpm capstone:calibrate --pack --live [--record]
+// Calls the real model (ANTHROPIC_API_KEY, CAPSTONE_MODEL) for every example and prints
+// the cost. --record saves each output as calibration/.recorded.json so CI can
+// replay it. Exits 1 when any example lands outside its tolerance.
+import "dotenv/config";
+import fs from "node:fs";
+import path from "node:path";
+import { anthropicGrader } from "../src/services/capstone/grade.js";
+import { formatCalibration, runCalibration } from "../src/services/capstone/calibration.js";
+import { calibrationDir, loadCalibrationExamples, parseRubricPack } from "../src/services/capstone/pack-loader.js";
+
+function arg(name: string): string | undefined {
+ const index = process.argv.indexOf(`--${name}`);
+ return index >= 0 ? process.argv[index + 1] : undefined;
+}
+
+async function main() {
+ const packArg = arg("pack");
+ if (!packArg) throw new Error("Pass --pack ");
+ const full = path.resolve(packArg);
+ const packDir = path.dirname(full);
+ const pack = parseRubricPack(fs.readFileSync(full, "utf8"), path.basename(full));
+ const live = process.argv.includes("--live");
+ const record = process.argv.includes("--record");
+ const packFile = path.basename(full);
+ const examples = loadCalibrationExamples(packDir, packFile);
+ if (examples.length === 0) throw new Error(`No examples in ${calibrationDir(packDir, packFile)}`);
+
+ const model = process.env.CAPSTONE_MODEL || "claude-sonnet-5-5";
+ const maxOutputTokens = Number(process.env.CAPSTONE_MAX_OUTPUT_TOKENS || 8000);
+ const maxInputTokens = Number(process.env.CAPSTONE_MAX_INPUT_TOKENS || 40000);
+ if (live) {
+ if (!process.env.ANTHROPIC_API_KEY) throw new Error("--live needs ANTHROPIC_API_KEY");
+ const worst = ((maxInputTokens * 2 + maxOutputTokens * 10) / 1_000_000) * examples.length;
+ console.log(`${pack.pack} v${pack.version}: ${examples.length} examples on ${model}. Worst case about $${worst.toFixed(2)}.`);
+ } else {
+ console.log(`${pack.pack} v${pack.version}: replaying ${examples.length} examples.`);
+ }
+
+ const outcomes = await runCalibration({
+ pack,
+ packDir,
+ packFile,
+ mode: live ? "live" : "replay",
+ liveGrader: live
+ ? anthropicGrader({ apiKey: process.env.ANTHROPIC_API_KEY!, model, maxOutputTokens, effort: (process.env.CAPSTONE_EFFORT as "low" | "medium" | "high") || "medium" })
+ : undefined,
+ githubToken: process.env.GITHUB_TOKEN,
+ maxInputTokens,
+ });
+ console.log(formatCalibration(outcomes));
+
+ if (live && record) {
+ for (const outcome of outcomes) {
+ if (outcome.output === null) continue;
+ const file = path.join(calibrationDir(packDir, packFile), `${outcome.name}.recorded.json`);
+ fs.writeFileSync(file, `${JSON.stringify(outcome.output, null, 2)}\n`);
+ console.log(` recorded ${path.relative(process.cwd(), file)}`);
+ }
+ }
+ if (outcomes.some((outcome) => !outcome.withinTolerance)) process.exitCode = 1;
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+});
diff --git a/server/scripts/course-source.ts b/server/scripts/course-source.ts
index 90f8a98ce..1d8c963a2 100644
--- a/server/scripts/course-source.ts
+++ b/server/scripts/course-source.ts
@@ -15,6 +15,7 @@ import { z } from "zod";
import { COURSE_CATEGORY_SOURCE_VALUES } from "../src/shared/domain/enums.js";
import { findCourseFamily } from "../src/shared/lib/course-tracks.js";
import { isSafeProductName } from "../src/shared/lib/content-tokens.js";
+import { CertificateSettingsSchema } from "../src/shared/lib/certificates.js";
import { ASSISTANT_GUIDANCE_MAX, ASSISTANT_STARTERS_MAX, ASSISTANT_STARTER_MAX, assistantEfforts } from "../src/services/ai/chapter-qa/assistant.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -128,6 +129,11 @@ export const CourseManifestSchema = z.object({
/** Short questions offered as one-tap chips in every chapter. */
starters: z.array(z.string().trim().min(3).max(ASSISTANT_STARTER_MAX)).max(ASSISTANT_STARTERS_MAX).optional(),
}).strict().optional(),
+ /**
+ * Completion certificate for this track (docs/16-capstone-checker-and-certificates.mdx). Issued when
+ * every chapter is complete and a mentor approved every capstone check in the course.
+ */
+ certificate: CertificateSettingsSchema.optional(),
/**
* Branded diagrams written as ```flow and ```sequence fences (docs/diagram-components-plan.md).
* `pnpm diagrams:export --course ` writes a static SVG of each one when exportSvg is on,
diff --git a/server/scripts/import-course.ts b/server/scripts/import-course.ts
index a4658dcee..84d47e4fe 100644
--- a/server/scripts/import-course.ts
+++ b/server/scripts/import-course.ts
@@ -37,6 +37,8 @@ import { VisualWalkthroughConfigSchema } from "../src/shared/blocks/visual-walkt
import { VisualDiagramConfigSchema } from "../src/shared/blocks/visual-diagram/schema.js";
import { RoadmapConfigSchema } from "../src/shared/blocks/roadmap/schema.js";
import { AiInterviewFenceSchema } from "../src/shared/blocks/ai-interview/schema.js";
+import { CapstoneFenceSchema } from "../src/shared/blocks/capstone-check/schema.js";
+import { loadRubricPackFile } from "../src/services/capstone/pack-loader.js";
import { COURSE_SOURCE_DIR, loadCourseManifest, type CourseManifest } from "./course-source.js";
import { recomputeEnrollmentRollup } from "../src/services/progress/compute.js";
@@ -75,7 +77,7 @@ const CHAPTER_NAMES: Record = {
export type PendingBlock = {
id: string;
- type: "QUIZ" | "OPEN_QUESTION" | "CHAPTER_RECAP" | "PROJECT_PREVIEW" | "LEARNING_OBJECTIVES" | "CODE" | "PREDICT" | "MUST_READ" | "VIDEO" | "VISUAL_WALKTHROUGH" | "VISUAL_DIAGRAM" | "ROADMAP" | "AI_INTERVIEW";
+ type: "QUIZ" | "OPEN_QUESTION" | "CHAPTER_RECAP" | "PROJECT_PREVIEW" | "LEARNING_OBJECTIVES" | "CODE" | "PREDICT" | "MUST_READ" | "VIDEO" | "VISUAL_WALKTHROUGH" | "VISUAL_DIAGRAM" | "ROADMAP" | "AI_INTERVIEW" | "CAPSTONE_CHECK";
config: Prisma.InputJsonValue;
required: boolean;
};
@@ -136,7 +138,9 @@ function blockPlaceholderTag(block: PendingBlock): string {
? "Roadmap"
: block.type === "AI_INTERVIEW"
? "AiInterview"
- : "OpenQuestion";
+ : block.type === "CAPSTONE_CHECK"
+ ? "CapstoneCheck"
+ : "OpenQuestion";
return `<${tag} id="${block.id}" />`;
}
@@ -662,6 +666,35 @@ function transformAiInterviewFences(body: string, pushBlock: (b: PendingBlock) =
});
}
+/**
+ * ```capstone``` fenced YAML -> CapstoneCheck block + placeholder tag. The fence names a
+ * rubric pack file in the course folder (capstone/.yaml); the parsed pack is stored
+ * in the block config, where it stays server-only. Any problem stops the import.
+ */
+function transformCapstoneFences(body: string, pushBlock: (b: PendingBlock) => string): string {
+ return body.replace(/```capstone\n([\s\S]*?)```/g, (_match, yamlText: string) => {
+ let parsed: unknown;
+ try {
+ parsed = loadYaml(yamlText);
+ } catch {
+ throw new Error("capstone fence: the YAML could not be read.");
+ }
+ const result = CapstoneFenceSchema.safeParse(parsed);
+ if (!result.success) {
+ const reasons = result.error.issues.map((issue) => `${issue.path.join(".") || "fence"}: ${issue.message}`);
+ throw new Error(`capstone fence is invalid (${reasons.join("; ")}).`);
+ }
+ const { required, ...fence } = result.data;
+ const rubric = loadRubricPackFile(COURSE_SOURCE_DIR, fence.pack);
+ return pushBlock({
+ id: ulid(),
+ type: "CAPSTONE_CHECK",
+ required: required ?? true,
+ config: { ...fence, rubric } as unknown as Prisma.InputJsonValue,
+ });
+ });
+}
+
/**
* One explanation string for the learner, shown after they answer. `explanations` holds one
* reason per option, in option order, so every choice (right or wrong) gets its own feedback.
@@ -836,6 +869,7 @@ export function parseLesson(moduleNum: number, moduleDir: string, fileName: stri
source = transformOpenQuestionFences(source, pushBlock);
source = transformPredictFences(source, pushBlock);
source = transformAiInterviewFences(source, pushBlock);
+ source = transformCapstoneFences(source, pushBlock);
source = transformCodeExerciseFences(source, pushBlock);
source = transformFaqSections(source);
const isMilestone = /-checklist\.md$/.test(fileName);
diff --git a/server/scripts/repository-course-publish.ts b/server/scripts/repository-course-publish.ts
index edd1f9303..6fb5e17e5 100644
--- a/server/scripts/repository-course-publish.ts
+++ b/server/scripts/repository-course-publish.ts
@@ -22,6 +22,7 @@ import type { SessionUser } from "../src/types/auth.js";
const SERVER_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const COURSES_ROOT = coursesRootForServer(SERVER_ROOT);
const REPO_ROOT = path.dirname(COURSES_ROOT);
+const CAPSTONE_PACK_FILE = /^capstone\/[a-z0-9-]+\.yaml$/;
const ZIP_FILE = /^(?:variants\/[a-z0-9-]+\/)?\d+-[a-z0-9-]+\/\d+\.\d+-[a-z0-9-]+\.md$/;
type Package = { slug: string; dir: string; manifest: ReturnType };
@@ -71,7 +72,9 @@ function filesForZip(dir: string): Record {
if (entry.isDirectory()) { visit(full); continue; }
if (!entry.isFile()) throw new Error(`Unsupported course package entry: ${full}`);
const relative = path.relative(dir, full).split(path.sep).join("/");
- if (relative !== "course.yaml" && relative !== "COURSE-OUTLINE.md" && !ZIP_FILE.test(relative)) {
+ // Calibration examples are for the checker's own tests; they never ship to learners.
+ if (relative.startsWith("capstone/calibration/")) continue;
+ if (relative !== "course.yaml" && relative !== "COURSE-OUTLINE.md" && !CAPSTONE_PACK_FILE.test(relative) && !ZIP_FILE.test(relative)) {
throw new Error(`Unsupported course package file: ${relative}`);
}
files[relative] = fs.readFileSync(full);
@@ -93,7 +96,7 @@ export function courseSourceHash(pkg: Package): string {
}
type CourseDetails = { title: string; description: string | null; projectGoal: string | null; difficulty: string | null;
- estimatedHours: number | null; category: CourseCategory; subcategory?: string | null; tags?: string[]; coverUrl: string | null; assistant: unknown };
+ estimatedHours: number | null; category: CourseCategory; subcategory?: string | null; tags?: string[]; coverUrl: string | null; assistant: unknown; certificate?: unknown };
// The repository owns a published course's details, so a publish copies them from course.yaml.
// ZIP updates leave details alone, which kept an early preview's title on System Design.
@@ -108,6 +111,7 @@ export function courseDetailsUpdate(manifest: Package["manifest"], current: Cour
...(manifest.coverUrl ? { coverUrl: manifest.coverUrl } : {}),
// Tutor settings are a JSON column, so they compare by value; no block clears whatever is stored.
assistant: manifest.assistant ?? null,
+ certificate: manifest.certificate ?? null,
};
const changed = Object.fromEntries(Object.entries(wanted).filter(([key, value]) =>
JSON.stringify(current[key as keyof CourseDetails] ?? null) !== JSON.stringify(value)));
@@ -197,12 +201,13 @@ async function publish(packages: Package[], instructorEmail: string) {
const sourceHash = courseSourceHash(pkg);
const prior = await prisma.course.findUnique({ where: { slug: targetSlug }, select: {
id: true, status: true, activeReleaseId: true, repositorySourceHash: true, repositoryReleaseId: true,
- title: true, description: true, projectGoal: true, difficulty: true, estimatedHours: true, category: true, subcategory: true, tags: true, coverUrl: true, assistant: true,
+ title: true, description: true, projectGoal: true, difficulty: true, estimatedHours: true, category: true, subcategory: true, tags: true, coverUrl: true, assistant: true, certificate: true,
} });
const details = prior ? courseDetailsUpdate(pkg.manifest, prior) : null;
if (prior && details) {
await prisma.course.update({ where: { id: prior.id }, data: { ...details,
- ...("assistant" in details ? { assistant: details.assistant ?? Prisma.DbNull } : {}) } as Prisma.CourseUpdateInput });
+ ...("assistant" in details ? { assistant: details.assistant ?? Prisma.DbNull } : {}),
+ ...("certificate" in details ? { certificate: details.certificate ?? Prisma.DbNull } : {}) } as Prisma.CourseUpdateInput });
console.log(`${pkg.slug}: course details updated (${Object.keys(details).join(", ")})`);
}
if (prior?.status === "PUBLISHED" && prior.repositorySourceHash === sourceHash &&
diff --git a/server/src/config/env.ts b/server/src/config/env.ts
index e130be978..a4cb8ddbd 100644
--- a/server/src/config/env.ts
+++ b/server/src/config/env.ts
@@ -91,6 +91,30 @@ const schema = z.object({
/** Questions the whole platform may answer per UTC day, the cap on the Anthropic bill. */
LEARNER_QA_GLOBAL_DAILY_LIMIT: z.coerce.number().int().min(1).max(100_000).default(500),
+ /**
+ * Capstone checker (docs/16-capstone-checker-and-certificates.mdx): automated checks plus an AI
+ * grade with the platform's ANTHROPIC_API_KEY, the same key the chapter questions use. On whenever
+ * the key is set; CAPSTONE_CHECK_ENABLED=false switches it off. Only courses with a capstone block
+ * use it, and the limits are counted in the database like the chapter question limits.
+ */
+ CAPSTONE_CHECK_ENABLED: z
+ .string()
+ .optional()
+ .transform((value) => value !== "false"),
+ CAPSTONE_MODEL: z.string().trim().min(1).default("claude-sonnet-5-5"),
+ CAPSTONE_EFFORT: z.enum(["low", "medium", "high"]).default("medium"),
+ /** Input budget for one check; the repo snapshot is trimmed to fit. */
+ CAPSTONE_MAX_INPUT_TOKENS: z.coerce.number().int().min(4_000).max(200_000).default(40_000),
+ CAPSTONE_MAX_OUTPUT_TOKENS: z.coerce.number().int().min(1_000).max(32_000).default(8_000),
+ /** Checks one learner may start per capstone block per UTC day. */
+ CAPSTONE_LEARNER_DAILY_LIMIT: z.coerce.number().int().min(1).max(50).default(3),
+ /** AI grades the whole platform may run per UTC day, the cap on the Anthropic bill. */
+ CAPSTONE_GLOBAL_DAILY_LIMIT: z.coerce.number().int().min(1).max(10_000).default(150),
+ /** Optional fine-grained GitHub token, public read only. Without it the API allows 60 calls an hour, about 12 checks. */
+ GITHUB_TOKEN: optionalString,
+ /** Edorax's LinkedIn company id for Add to profile; without it LinkedIn gets the name "Edorax" as text. */
+ CERTIFICATE_LINKEDIN_ORG_ID: optionalString,
+
/** Private Cloudflare R2 bucket used for instructor-uploaded course images. */
R2_ENDPOINT: optionalString,
R2_BUCKET: optionalString,
@@ -164,3 +188,5 @@ export const rateLimitsEnabled = !(env.DISABLE_RATE_LIMITS && !isProduction);
export const isTest = env.NODE_ENV === "test";
/** Chapter questions need both the switch and a key; either missing hides the feature. */
export const learnerQaAvailable = env.LEARNER_QA_ENABLED && Boolean(env.ANTHROPIC_API_KEY);
+/** Capstone AI checks need both the switch and the key; automated checks never run without them either. */
+export const capstoneCheckAvailable = env.CAPSTONE_CHECK_ENABLED && Boolean(env.ANTHROPIC_API_KEY);
diff --git a/server/src/controllers/capstone.ts b/server/src/controllers/capstone.ts
new file mode 100644
index 000000000..d4fdcf80a
--- /dev/null
+++ b/server/src/controllers/capstone.ts
@@ -0,0 +1,23 @@
+import type { Request, Response } from "express";
+import { ok } from "../utils/responses.js";
+import { getCapstoneState, sendCapstoneToMentor, startCapstoneCheck } from "../services/capstone/service.js";
+
+// The learner's capstone checks for one block. The rubric pack never appears in a response.
+
+const blockId = (req: Request) => req.params.blockId as string;
+
+// GET /api/v1/blocks/:blockId/capstone
+export async function state(req: Request, res: Response) {
+ ok(res, { state: await getCapstoneState(blockId(req), req.user) });
+}
+
+// POST /api/v1/blocks/:blockId/capstone/check
+export async function check(req: Request, res: Response) {
+ ok(res, { check: await startCapstoneCheck({ blockId: blockId(req), user: req.user, body: req.body }) });
+}
+
+// POST /api/v1/blocks/:blockId/capstone/send
+export async function send(req: Request, res: Response) {
+ const { checkId } = req.body as { checkId: string };
+ ok(res, { check: await sendCapstoneToMentor({ blockId: blockId(req), user: req.user, checkId }) });
+}
diff --git a/server/src/controllers/certificates.ts b/server/src/controllers/certificates.ts
new file mode 100644
index 000000000..4d719314a
--- /dev/null
+++ b/server/src/controllers/certificates.ts
@@ -0,0 +1,26 @@
+import type { Request, Response } from "express";
+import { ok } from "../utils/responses.js";
+import { claimCertificate, getMyCertificate, getPublicCertificate, revokeCertificate } from "../services/certificates/service.js";
+
+// GET /api/v1/certificates/verify/:code (public)
+export async function verify(req: Request, res: Response) {
+ res.setHeader("Cache-Control", "public, max-age=60");
+ ok(res, { certificate: await getPublicCertificate(req.params.code as string) });
+}
+
+// GET /api/v1/courses/:courseSlug/certificate
+export async function mine(req: Request, res: Response) {
+ ok(res, await getMyCertificate(req.params.courseSlug as string, req.user));
+}
+
+// POST /api/v1/courses/:courseSlug/certificate
+export async function claim(req: Request, res: Response) {
+ const { recipientName } = req.body as { recipientName: string };
+ ok(res, { certificate: await claimCertificate(req.params.courseSlug as string, req.user, { recipientName }) });
+}
+
+// POST /api/v1/certificates/:code/revoke (course mentors and admins)
+export async function revoke(req: Request, res: Response) {
+ const { reason } = req.body as { reason: string };
+ ok(res, { certificate: await revokeCertificate(req.user!, req.params.code as string, reason) });
+}
diff --git a/server/src/index.ts b/server/src/index.ts
index 20c812841..04eac4d60 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -6,10 +6,13 @@ import { createApp } from "./app.js";
import { startScheduler } from "./jobs/scheduler.js";
import { startNotificationWorker } from "./jobs/notification-worker.js";
import { startRealtime } from "./realtime/sse.js";
+import { failStaleCapstoneChecks } from "./services/capstone/service.js";
async function start() {
await prisma.$connect();
logger.info("database connection established");
+ // Capstone checks run in process; any left running by the previous process will never finish.
+ failStaleCapstoneChecks().catch((error) => logger.warn({ err: error }, "could not clear stale capstone checks"));
const server = http.createServer(createApp());
// Keep idle connections longer than any client or proxy in front of us does
diff --git a/server/src/routes/v1/blocks.ts b/server/src/routes/v1/blocks.ts
index a254963a8..ad44746cd 100644
--- a/server/src/routes/v1/blocks.ts
+++ b/server/src/routes/v1/blocks.ts
@@ -1,11 +1,13 @@
import { Router } from "express";
import * as blocks from "../../controllers/blocks.js";
import * as aiInterview from "../../controllers/ai-interview.js";
+import * as capstone from "../../controllers/capstone.js";
import { authenticate, optionalAuth } from "../../middlewares/authenticate.js";
import { userRateLimit } from "../../middlewares/rate-limit.js";
import { validate } from "../../middlewares/validate.js";
import { aiInterviewAnswerBody, aiInterviewSessionBody, aiInterviewStartBody } from "../../validations/ai.js";
import { respondBody, respondParams } from "../../validations/blocks.js";
+import { capstoneCheckBody, capstoneSendBody } from "../../validations/capstone.js";
import { validateParams } from "../../middlewares/validate.js";
const router = Router();
@@ -43,4 +45,16 @@ router.post("/:blockId/ai-interview/answer", authenticate, validate({ params: re
router.post("/:blockId/ai-interview/finish", authenticate, validate({ params: respondParams, body: aiInterviewSessionBody }), aiInterviewLimit("ai-interview-finish", 10), aiInterview.finish);
router.post("/:blockId/ai-interview/abandon", authenticate, validate({ params: respondParams, body: aiInterviewSessionBody }), aiInterviewLimit("ai-interview-abandon", 20), aiInterview.abandon);
+// Capstone checks: GitHub reads and an AI grade on the platform key. The service also
+// enforces the per-day limits in the database; these stop bursts before any work starts.
+router.get("/:blockId/capstone", authenticate, validateParams(respondParams), capstone.state);
+router.post(
+ "/:blockId/capstone/check",
+ authenticate,
+ validate({ params: respondParams, body: capstoneCheckBody }),
+ userRateLimit({ scope: "capstone-check", max: 5, windowMs: 10 * 60_000, keyFrom: (req) => req.params.blockId as string, message: "Wait a few minutes before you check again." }),
+ capstone.check
+);
+router.post("/:blockId/capstone/send", authenticate, validate({ params: respondParams, body: capstoneSendBody }), capstone.send);
+
export default router;
diff --git a/server/src/routes/v1/certificates.ts b/server/src/routes/v1/certificates.ts
new file mode 100644
index 000000000..efd9ba1fe
--- /dev/null
+++ b/server/src/routes/v1/certificates.ts
@@ -0,0 +1,13 @@
+import { Router } from "express";
+import * as certificates from "../../controllers/certificates.js";
+import { authenticate } from "../../middlewares/authenticate.js";
+import { validate, validateParams } from "../../middlewares/validate.js";
+import { certificateCodeParams, certificateRevokeBody } from "../../validations/capstone.js";
+
+const router = Router();
+
+// Public verify page data: anyone with the link can check a certificate. The global API limiter applies.
+router.get("/verify/:code", validateParams(certificateCodeParams), certificates.verify);
+router.post("/:code/revoke", authenticate, validate({ params: certificateCodeParams, body: certificateRevokeBody }), certificates.revoke);
+
+export default router;
diff --git a/server/src/routes/v1/courses.ts b/server/src/routes/v1/courses.ts
index 8cb7a5a54..68b5ebba6 100644
--- a/server/src/routes/v1/courses.ts
+++ b/server/src/routes/v1/courses.ts
@@ -1,6 +1,8 @@
import { Router } from "express";
import * as courses from "../../controllers/courses.js";
import * as enrollments from "../../controllers/enrollments.js";
+import * as certificates from "../../controllers/certificates.js";
+import { certificateClaimBody } from "../../validations/capstone.js";
import { authenticate, optionalAuth } from "../../middlewares/authenticate.js";
import { requireVerified } from "../../middlewares/require-verified.js";
import { validateBody, validateParams } from "../../middlewares/validate.js";
@@ -9,6 +11,8 @@ import { chapterPathParams, chooseSpecialisationBody, chooseVariantBody, courseS
const router = Router();
router.get("/:courseSlug/continue", optionalAuth, validateParams(courseSlugParams), courses.continueChapter);
+router.get("/:courseSlug/certificate", authenticate, requireVerified, validateParams(courseSlugParams), certificates.mine);
+router.post("/:courseSlug/certificate", authenticate, requireVerified, validateParams(courseSlugParams), validateBody(certificateClaimBody), certificates.claim);
router.get("/:courseSlug/overview", authenticate, requireVerified, validateParams(courseSlugParams), courses.overview);
router.get("/:courseSlug/chapters/:chapterSlug", authenticate, requireVerified, validateParams(chapterPathParams), courses.chapter);
router.get("/:courseSlug/chapters/:chapterSlug/download", authenticate, requireVerified, validateParams(chapterPathParams), courses.downloadChapter);
diff --git a/server/src/routes/v1/index.ts b/server/src/routes/v1/index.ts
index 88049b56b..790825f32 100644
--- a/server/src/routes/v1/index.ts
+++ b/server/src/routes/v1/index.ts
@@ -18,6 +18,7 @@ import activityRoutes from "./activity.js";
import notificationRoutes from "./notifications.js";
import engagementRoutes from "./engagement.js";
import eventRoutes from "./events.js";
+import certificateRoutes from "./certificates.js";
// First-party API, versioned. JSON routes use the { success, data } envelope;
// the media route streams image bytes.
@@ -42,5 +43,6 @@ router.use("/activity", activityRoutes);
router.use("/notifications", notificationRoutes);
router.use("/events", engagementRoutes);
router.use("/events", eventRoutes);
+router.use("/certificates", certificateRoutes);
export default router;
diff --git a/server/src/services/blocks/respond.ts b/server/src/services/blocks/respond.ts
index be59ced14..cdc4469c1 100644
--- a/server/src/services/blocks/respond.ts
+++ b/server/src/services/blocks/respond.ts
@@ -144,6 +144,9 @@ export async function submitBlockResponse(input: {
if (found.type === "AI_INTERVIEW") {
throw new BadRequestError("Finish the interview to complete this block.", "USE_AI_INTERVIEW");
}
+ if (found.type === "CAPSTONE_CHECK") {
+ throw new BadRequestError("Use Check my capstone to submit this block.", "USE_CAPSTONE_CHECK");
+ }
});
const previousAttempts = await prisma.response.count({ where: { blockId, userId: learner.id } });
diff --git a/server/src/services/blocks/state.ts b/server/src/services/blocks/state.ts
index aee1abe26..e22e508f4 100644
--- a/server/src/services/blocks/state.ts
+++ b/server/src/services/blocks/state.ts
@@ -1,6 +1,8 @@
import type { BlockState, ContentBlockState } from "../../shared/api/blocks.js";
import { blockDefinitions } from "../../shared/blocks/definitions.js";
import { AiInterviewConfigSchema, type SanitizedAiInterviewConfig } from "../../shared/blocks/ai-interview/schema.js";
+import { CapstoneCheckConfigSchema, capstoneClientConfig } from "../../shared/blocks/capstone-check/schema.js";
+import { loadCapstoneState } from "../capstone/service.js";
import { CodeConfigSchema, type SanitizedCodeConfig } from "../../shared/blocks/code/schema.js";
import { MustReadConfigSchema } from "../../shared/blocks/must-read/schema.js";
import {
@@ -190,6 +192,15 @@ async function blockStateFor(
initialState: await loadAiInterviewInitialState(user?.id, blockId, config),
};
}
+ case "CAPSTONE_CHECK": {
+ const config = CapstoneCheckConfigSchema.parse(block.config);
+ return {
+ type: "CAPSTONE_CHECK",
+ // Built field by field: the rubric pack (levels, weights, guidance) stays on the server.
+ config: capstoneClientConfig(config),
+ initialState: user ? await loadCapstoneState(blockId, user.id) : null,
+ };
+ }
case "STEPS":
case "VIDEO":
case "CHAPTER_RECAP":
diff --git a/server/src/services/capstone/automated.ts b/server/src/services/capstone/automated.ts
new file mode 100644
index 000000000..54b5e76d4
--- /dev/null
+++ b/server/src/services/capstone/automated.ts
@@ -0,0 +1,193 @@
+import { automatedSpecs, stopsRun, type AutomatedCheckSpec, type RubricPack } from "../../shared/blocks/capstone-check/schema.js";
+import { blocksGrade, type AutomatedCheckResult } from "../../shared/blocks/capstone-check/report.js";
+import { parseRepoUrl, type CapstoneRepoSource, type RepoRef, type RepoTreeEntry } from "./github.js";
+import type { PublicPage } from "./safe-fetch.js";
+
+// Deterministic checks that run before (and instead of, when a hard gate fails) the
+// model. Every message is one sentence the learner can act on.
+
+/** Glob with `**`, `*` and `?`, matched against a full repo path. */
+export function globToRegExp(glob: string): RegExp {
+ let out = "";
+ for (let i = 0; i < glob.length; i++) {
+ const char = glob[i]!;
+ if (char === "*") {
+ if (glob[i + 1] === "*") {
+ const slash = glob[i + 2] === "/";
+ out += slash ? "(?:.*/)?" : ".*";
+ i += slash ? 2 : 1;
+ } else out += "[^/]*";
+ } else if (char === "?") out += "[^/]";
+ else out += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
+ }
+ return new RegExp(`^${out}$`);
+}
+
+export function matchesAny(path: string, globs: string[]): boolean {
+ return globs.some((glob) => globToRegExp(glob).test(path));
+}
+
+export type RepoFacts = {
+ ref: RepoRef;
+ defaultBranch: string;
+ commitSha: string;
+ tree: RepoTreeEntry[];
+ treeTruncated: boolean;
+};
+
+export type AutomatedRun = {
+ results: AutomatedCheckResult[];
+ repo: RepoFacts | null;
+ /** Live page text for the grader, when the pack asked for a live URL and it answered. */
+ livePage: { url: string; text: string } | null;
+ /** True when a hard gate failed, so the model must not be called. */
+ stopped: boolean;
+};
+
+export type AutomatedDeps = {
+ source: CapstoneRepoSource;
+ fetchPage: (url: string) => Promise;
+};
+
+function siteUrl(base: string, path: string | undefined): string {
+ if (!path) return base;
+ return new URL(path, base.endsWith("/") ? base : `${base}/`).toString();
+}
+
+function pageText(html: string): string {
+ return html
+ .replace(/' });
+let graderCalls = 0;
+const grader: GraderModel = {
+ async grade(prompt) {
+ graderCalls++;
+ // Grader guidance is for the model; it must never reach the learner (checked in the state test).
+ expect(prompt.user).toContain("SECRET grader guidance");
+ return {
+ model: "claude-sonnet-5-5",
+ inputTokens: 20_000,
+ outputTokens: 3_000,
+ output: {
+ summary: "Links are tracked and tested.",
+ criteria: [
+ { id: "proposal-links", score: 2, evidence: ["src/links.py: links"], missing: [] },
+ { id: "readme", score: 1, evidence: ["README.md"], missing: ["deploy steps"] },
+ { id: "tests", score: 2, evidence: [], missing: [] },
+ ],
+ whatIsLeft: [{ criterionId: "readme", title: "README", steps: ["Add deploy steps."] }],
+ qualityNotes: { strengths: ["Small functions."], improvements: ["Persist visits."] },
+ confidence: "high",
+ },
+ };
+ },
+};
+const deps = { source, fetchPage, grader, inline: true };
+const learnerUser = () => prisma.user.findUniqueOrThrow({ where: { id: fx.learner.id } }).then((u) => ({ ...u, testerEnabled: false }));
+
+beforeAll(async () => {
+ fx = await createCourseFixture();
+ const block = await prisma.block.create({
+ data: {
+ id: `blk-capstone-${fx.tag}`,
+ chapterId: fx.chapterA.id,
+ type: "CAPSTONE_CHECK",
+ order: 9,
+ required: true,
+ config: { title: "Ship your site", pack: "capstone/rubric.yaml", rubric: { ...rubric, guidance: "SECRET grader guidance" } },
+ },
+ });
+ blockId = block.id;
+ await prisma.course.update({
+ where: { id: fx.course.id },
+ data: { certificate: { title: "Level 2: Personal Site and Outreach Engine", code: "L2", skills: ["Personal site", "Link tracking"] } },
+ });
+ mentorToken = (await createSession(fx.mentor.id, randomBytes(32).toString("hex"))).sessionToken;
+ env.CAPSTONE_LEARNER_DAILY_LIMIT = 10;
+});
+
+afterAll(async () => {
+ await prisma.certificate.deleteMany({ where: { courseId: fx.course.id } });
+ await prisma.capstoneCheck.deleteMany({ where: { blockId } });
+ await prisma.review.deleteMany({ where: { response: { blockId } } });
+ await prisma.response.deleteMany({ where: { blockId } });
+ await prisma.blockStats.deleteMany({ where: { blockId } });
+ await prisma.xpEntry.deleteMany({ where: { userId: fx.learner.id } });
+ await prisma.notification.deleteMany({ where: { userId: { in: [fx.learner.id, fx.mentor.id] } } });
+ await prisma.block.deleteMany({ where: { id: blockId } });
+ await fx.cleanup();
+});
+
+const authed = (req: request.Test, token: string | null = fx.learnerToken) => (token ? req.set("Authorization", `Bearer ${token}`) : req);
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+describe("capstone checks", () => {
+ it("shows the block with a checklist and no rubric", async () => {
+ const res = await authed(request(app).get(`/api/v1/blocks/${blockId}/state`));
+ expect(res.status).toBe(200);
+ expect(res.body.data.type).toBe("CAPSTONE_CHECK");
+ expect(res.body.data.config).toMatchObject({ title: "Ship your site", liveUrl: "required" });
+ expect(res.body.data.config.checklist).toHaveLength(3);
+ expect(JSON.stringify(res.body)).not.toContain("SECRET");
+ expect(JSON.stringify(res.body)).not.toContain("lookFor");
+ expect(res.body.data.initialState).toMatchObject({ checks: [], dailyLimit: 10 });
+ });
+
+ it("refuses the generic respond route, bad links and a missing live URL", async () => {
+ const respond = await authed(request(app).post(`/api/v1/blocks/${blockId}/respond`).send({ payload: {} }));
+ expect(respond.body.code ?? respond.status).not.toBe(200);
+ const user = await learnerUser();
+ await expect(startCapstoneCheck({ blockId, user, body: { repoUrl: "https://gitlab.com/a/b" } }, deps)).rejects.toThrow("Paste the repo link");
+ await expect(startCapstoneCheck({ blockId, user, body: { repoUrl: "https://github.com/me/site" } }, deps)).rejects.toThrow("Add the live link");
+ });
+
+ it("stops at red CI without calling the model or queueing the mentor", async () => {
+ ciState = "failure";
+ const check = await startCapstoneCheck({ blockId, user: await learnerUser(), body: { repoUrl: "https://github.com/me/site", liveUrl: "https://example.dev" } }, deps);
+ expect(check.status).toBe("DONE");
+ expect(check.report).toMatchObject({ percent: 0, completed: false, graded: false });
+ expect(check.sentToMentor).toBe(false);
+ expect(graderCalls).toBe(0);
+ ciState = "success";
+ });
+
+ it("grades a passing submission and puts it in the mentor queue", async () => {
+ await prisma.capstoneCheck.updateMany({ where: { blockId }, data: { createdAt: new Date(Date.now() - 120_000) } });
+ const check = await startCapstoneCheck({ blockId, user: await learnerUser(), body: { repoUrl: "https://github.com/me/site", liveUrl: "https://example.dev" } }, deps);
+ expect(check.error).toBeNull();
+ expect(check.status).toBe("DONE");
+ expect(check.report).toMatchObject({ percent: 90, completed: true, graded: true });
+ expect(check.sentToMentor).toBe(true);
+ expect(graderCalls).toBe(1);
+ const row = await prisma.capstoneCheck.findUniqueOrThrow({ where: { id: check.id } });
+ expect(row).toMatchObject({ aiCalled: true, inputTokens: 20_000, outputTokens: 3_000, costMicroUsd: 70_000, commitSha: "goodsha" });
+ const response = await prisma.response.findUniqueOrThrow({ where: { id: row.responseId! } });
+ expect(response).toMatchObject({ status: "PENDING_REVIEW", score: 90, maxScore: 100 });
+ });
+
+ it("reuses the grade for the same commit without another model call", async () => {
+ await prisma.capstoneCheck.updateMany({ where: { blockId }, data: { createdAt: new Date(Date.now() - 120_000) } });
+ const check = await startCapstoneCheck({ blockId, user: await learnerUser(), body: { repoUrl: "https://github.com/me/site", liveUrl: "https://example.dev" } }, deps);
+ expect(check.reused).toBe(true);
+ expect(graderCalls).toBe(1);
+ // One pending item per learner per block: the newer check replaced the waiting one.
+ expect(await prisma.response.count({ where: { blockId, status: "PENDING_REVIEW" } })).toBe(1);
+ });
+
+ it("holds the platform-wide daily AI budget", async () => {
+ const saved = env.CAPSTONE_GLOBAL_DAILY_LIMIT;
+ env.CAPSTONE_GLOBAL_DAILY_LIMIT = 1;
+ try {
+ // Today already holds one AI grade (the passing check). A new commit needs a new grade.
+ await prisma.capstoneCheck.updateMany({ where: { blockId }, data: { createdAt: new Date(Date.now() - 120_000) } });
+ const other = { ...deps, source: { ...source, repo: async () => ({ defaultBranch: "main", headSha: "newsha", isPrivate: false }) } };
+ const check = await startCapstoneCheck({ blockId, user: await learnerUser(), body: { repoUrl: "https://github.com/me/site", liveUrl: "https://example.dev" } }, other);
+ expect(check.status).toBe("FAILED");
+ expect(check.error).toContain("paused for today");
+ expect(graderCalls).toBe(1);
+ } finally {
+ env.CAPSTONE_GLOBAL_DAILY_LIMIT = saved;
+ }
+ });
+
+ it("shows the mentor the report, then the approval unlocks the certificate", async () => {
+ const pending = await prisma.response.findFirstOrThrow({ where: { blockId, status: "PENDING_REVIEW" } });
+ const detail = await authed(request(app).get(`/api/v1/teach/review/${pending.id}`), mentorToken);
+ expect(detail.status).toBe(200);
+ expect(detail.body.data.capstone.current.report.percent).toBe(90);
+ // The waiting item carries the latest check, a free reuse; the paid grade is in the history.
+ expect(detail.body.data.capstone.current).toMatchObject({ reused: true, costUsd: 0 });
+ expect(detail.body.data.capstone.history.some((c: { costUsd: number }) => Math.abs(c.costUsd - 0.07) < 1e-9)).toBe(true);
+
+ const before = await authed(request(app).get(`/api/v1/courses/${fx.course.slug}/certificate`));
+ expect(before.body.data.eligibility).toMatchObject({ offered: true, eligible: false });
+
+ const review = await authed(request(app).post(`/api/v1/teach/review/${pending.id}`).send({ verdict: "APPROVED", feedback: "Confirmed the AI check.", score: 1 }), mentorToken);
+ expect(review.status).toBe(204);
+ await prisma.enrollment.update({ where: { id: fx.enrollment.id }, data: { completedAt: new Date(), percentComplete: 100 } });
+
+ const ready = await authed(request(app).get(`/api/v1/courses/${fx.course.slug}/certificate`));
+ expect(ready.body.data.eligibility).toEqual({ offered: true, eligible: true, missing: [] });
+
+ const claim = await authed(request(app).post(`/api/v1/courses/${fx.course.slug}/certificate`).send({ recipientName: "Ayesha Khan" }));
+ expect(claim.status).toBe(200);
+ const certificate = claim.body.data.certificate;
+ expect(certificate).toMatchObject({ recipientName: "Ayesha Khan", title: "Level 2: Personal Site and Outreach Engine", issuerName: "Test Mentor", organization: "Dev Weekends", skills: ["Personal site", "Link tracking"] });
+ expect(certificate.code).toMatch(/^DW-L2-[0-9A-Z]{8}$/);
+ expect(certificate.evidence[0]).toMatchObject({ repoUrl: "https://github.com/me/site", percent: 90 });
+ expect(certificate.linkedInAddUrl).toContain("startTask=CERTIFICATION_NAME");
+ // The course's trainer signs; LinkedIn lists Edorax, never Dev Weekends, as the issuing organisation.
+ expect(new URL(certificate.linkedInAddUrl).searchParams.get("organizationName")).toBe("Edorax");
+
+ const again = await authed(request(app).post(`/api/v1/courses/${fx.course.slug}/certificate`).send({ recipientName: "Someone Else" }));
+ expect(again.body.data.certificate.code).toBe(certificate.code);
+
+ const verify = await request(app).get(`/api/v1/certificates/verify/${certificate.code}`);
+ expect(verify.status).toBe(200);
+ expect(verify.body.data.certificate).toMatchObject({ recipientName: "Ayesha Khan", revokedAt: null });
+ expect((await request(app).get("/api/v1/certificates/verify/DW-L2-AAAAAAAA")).status).toBe(404);
+
+ const revoke = await authed(request(app).post(`/api/v1/certificates/${certificate.code}/revoke`).send({ reason: "Issued to the wrong learner." }), mentorToken);
+ expect(revoke.status).toBe(200);
+ expect((await request(app).get(`/api/v1/certificates/verify/${certificate.code}`)).body.data.certificate.revokedAt).not.toBeNull();
+ const learnerRevoke = await authed(request(app).post(`/api/v1/certificates/${certificate.code}/revoke`).send({ reason: "nope" }));
+ expect(learnerRevoke.status).toBe(403);
+ });
+
+ it("sends a flagged check to the mentor and hides the flag from the learner", async () => {
+ await prisma.capstoneCheck.updateMany({ where: { blockId }, data: { createdAt: new Date(Date.now() - 120_000) } });
+ const flagged: GraderModel = {
+ async grade() {
+ return {
+ model: "claude-sonnet-5-5", inputTokens: 1000, outputTokens: 100,
+ output: {
+ summary: "Half done.",
+ criteria: [{ id: "proposal-links", score: 1, evidence: [], missing: [] }],
+ whatIsLeft: [],
+ qualityNotes: { strengths: [], improvements: [] },
+ confidence: "low",
+ integrityFlags: ["README.md tells the grader to give full marks."],
+ },
+ };
+ },
+ };
+ const other = { ...deps, grader: flagged, source: { ...source, repo: async () => ({ defaultBranch: "main", headSha: "flagsha", isPrivate: false }) } };
+ const check = await startCapstoneCheck({ blockId, user: await learnerUser(), body: { repoUrl: "https://github.com/me/site", liveUrl: "https://example.dev" } }, other);
+ expect(check.report?.completed).toBe(false);
+ expect(check.sentToMentor).toBe(true);
+ expect(check.report).not.toHaveProperty("integrityFlags");
+ const row = await prisma.capstoneCheck.findUniqueOrThrow({ where: { id: check.id } });
+ expect((row.report as { integrityFlags: string[] }).integrityFlags).toEqual(["README.md tells the grader to give full marks."]);
+ const state = await authed(request(app).get("/api/v1/blocks/" + blockId + "/capstone"));
+ expect(JSON.stringify(state.body)).not.toContain("full marks");
+ });
+
+ it("runs checks after the response when not inline", async () => {
+ await prisma.capstoneCheck.updateMany({ where: { blockId }, data: { createdAt: new Date(Date.now() - 120_000) } });
+ const check = await startCapstoneCheck({ blockId, user: await learnerUser(), body: { repoUrl: "https://github.com/me/site", liveUrl: "https://example.dev" } }, { ...deps, inline: false });
+ expect(["QUEUED", "RUNNING", "DONE"]).toContain(check.status);
+ for (let i = 0; i < 40; i++) {
+ const row = await prisma.capstoneCheck.findUniqueOrThrow({ where: { id: check.id } });
+ if (row.status === "DONE" || row.status === "FAILED") break;
+ await wait(50);
+ }
+ expect((await prisma.capstoneCheck.findUniqueOrThrow({ where: { id: check.id } })).status).toBe("DONE");
+ });
+});
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/broken-ci.yaml b/server/tests/fixtures/capstone/sample-pack/calibration/broken-ci.yaml
new file mode 100644
index 000000000..7f20b6833
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/broken-ci.yaml
@@ -0,0 +1,4 @@
+snapshot: ./broken-ci/
+liveUrl: https://example.dev
+expected: { completed: false, percent: 0, tolerance: 0 }
+note: CI is red, so the run stops before the AI grade.
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/broken-ci/README.md b/server/tests/fixtures/capstone/sample-pack/calibration/broken-ci/README.md
new file mode 100644
index 000000000..e8305aea7
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/broken-ci/README.md
@@ -0,0 +1 @@
+# Proposal site
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/broken-ci/automated.json b/server/tests/fixtures/capstone/sample-pack/calibration/broken-ci/automated.json
new file mode 100644
index 000000000..d00818af6
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/broken-ci/automated.json
@@ -0,0 +1,7 @@
+[
+ { "id": "repo_public", "check": "repo_public", "status": "pass", "message": "Found the repo." },
+ { "id": "ci_green", "check": "ci_green", "status": "fail", "message": "GitHub Actions failed on the latest commit: test." },
+ { "id": "files", "check": "files", "status": "pass", "message": "All the required files are in the repo." },
+ { "id": "live_ok", "check": "live_ok", "status": "pass", "message": "The live site loads." },
+ { "id": "live_contains", "check": "live_contains", "status": "pass", "message": "The live page contains \"data-analytics\"." }
+]
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/near-miss.recorded.json b/server/tests/fixtures/capstone/sample-pack/calibration/near-miss.recorded.json
new file mode 100644
index 000000000..7cad496e8
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/near-miss.recorded.json
@@ -0,0 +1,14 @@
+{
+ "summary": "Proposal links exist but visits are not recorded. The README is complete.",
+ "criteria": [
+ { "id": "proposal-links", "score": 1, "evidence": ["src/links.py: a fixed list of links"], "missing": ["Visit recording"] },
+ { "id": "readme", "score": 2, "evidence": ["README.md: run, test and deploy"], "missing": [] },
+ { "id": "tests", "score": 1, "evidence": [], "missing": ["A test for recording a visit"] }
+ ],
+ "whatIsLeft": [
+ { "criterionId": "proposal-links", "title": "Tracked proposal links", "steps": ["Add a recordVisit function.", "Call it when a proposal page loads."] },
+ { "criterionId": "tests", "title": "Tests for the link tracker", "steps": ["Write a test that records a visit."] }
+ ],
+ "qualityNotes": { "strengths": ["The README is easy to follow."], "improvements": ["Links are hard-coded."] },
+ "confidence": "medium"
+}
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/near-miss.yaml b/server/tests/fixtures/capstone/sample-pack/calibration/near-miss.yaml
new file mode 100644
index 000000000..0776dceb4
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/near-miss.yaml
@@ -0,0 +1,4 @@
+snapshot: ./near-miss/
+liveUrl: https://example.dev
+expected: { completed: false, percent: 60, tolerance: 5 }
+note: Links exist but visits are not recorded, and the analytics snippet is missing.
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/near-miss/README.md b/server/tests/fixtures/capstone/sample-pack/calibration/near-miss/README.md
new file mode 100644
index 000000000..c694eeff2
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/near-miss/README.md
@@ -0,0 +1,3 @@
+# Proposal site
+
+Run it, test it with `npm test`, deploy it with `npm run deploy`.
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/near-miss/automated.json b/server/tests/fixtures/capstone/sample-pack/calibration/near-miss/automated.json
new file mode 100644
index 000000000..5f801a714
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/near-miss/automated.json
@@ -0,0 +1,7 @@
+[
+ { "id": "repo_public", "check": "repo_public", "status": "pass", "message": "Found the repo." },
+ { "id": "ci_green", "check": "ci_green", "status": "pass", "message": "GitHub Actions passed." },
+ { "id": "files", "check": "files", "status": "pass", "message": "All the required files are in the repo." },
+ { "id": "live_ok", "check": "live_ok", "status": "pass", "message": "The live site loads." },
+ { "id": "live_contains", "check": "live_contains", "status": "fail", "message": "The live page does not contain \"data-analytics\" yet." }
+]
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/near-miss/live.html b/server/tests/fixtures/capstone/sample-pack/calibration/near-miss/live.html
new file mode 100644
index 000000000..5e539dec9
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/near-miss/live.html
@@ -0,0 +1 @@
+
Hi
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/near-miss/src/links.py b/server/tests/fixtures/capstone/sample-pack/calibration/near-miss/src/links.py
new file mode 100644
index 000000000..1381ca3d7
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/near-miss/src/links.py
@@ -0,0 +1 @@
+LINKS = ["/p/acme"]
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/strong.recorded.json b/server/tests/fixtures/capstone/sample-pack/calibration/strong.recorded.json
new file mode 100644
index 000000000..c29f730da
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/strong.recorded.json
@@ -0,0 +1,11 @@
+{
+ "summary": "The site has tracked proposal links and tests for them. The README only says how to run it.",
+ "criteria": [
+ { "id": "proposal-links", "score": 2, "evidence": ["src/links.py: one URL per proposal and a visit counter"], "missing": [] },
+ { "id": "readme", "score": 1, "evidence": ["README.md: has a run command"], "missing": ["How to test and deploy"] },
+ { "id": "tests", "score": 2, "evidence": ["tests/test_links.py: covers creating a link and a visit"], "missing": [] }
+ ],
+ "whatIsLeft": [{ "criterionId": "readme", "title": "README a mentor can follow", "steps": ["Add a Test section with the test command.", "Add a Deploy section."] }],
+ "qualityNotes": { "strengths": ["Small, focused functions."], "improvements": ["Store visits somewhere that survives a restart."] },
+ "confidence": "high"
+}
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/strong.yaml b/server/tests/fixtures/capstone/sample-pack/calibration/strong.yaml
new file mode 100644
index 000000000..136fefff4
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/strong.yaml
@@ -0,0 +1,4 @@
+snapshot: ./strong/
+liveUrl: https://example.dev
+expected: { completed: true, percent: 90, tolerance: 5 }
+note: Tracked links and tests; README covers running only.
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/strong/README.md b/server/tests/fixtures/capstone/sample-pack/calibration/strong/README.md
new file mode 100644
index 000000000..97efe30fa
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/strong/README.md
@@ -0,0 +1,3 @@
+# Proposal site
+
+Run it with `python -m site`.
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/strong/automated.json b/server/tests/fixtures/capstone/sample-pack/calibration/strong/automated.json
new file mode 100644
index 000000000..e99f8720d
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/strong/automated.json
@@ -0,0 +1,7 @@
+[
+ { "id": "repo_public", "check": "repo_public", "status": "pass", "message": "Found the repo." },
+ { "id": "ci_green", "check": "ci_green", "status": "pass", "message": "GitHub Actions passed." },
+ { "id": "files", "check": "files", "status": "pass", "message": "All the required files are in the repo." },
+ { "id": "live_ok", "check": "live_ok", "status": "pass", "message": "The live site loads." },
+ { "id": "live_contains", "check": "live_contains", "status": "pass", "message": "The live page contains \"data-analytics\"." }
+]
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/strong/live.html b/server/tests/fixtures/capstone/sample-pack/calibration/strong/live.html
new file mode 100644
index 000000000..46be21f59
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/strong/live.html
@@ -0,0 +1 @@
+
Hi
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/strong/src/links.py b/server/tests/fixtures/capstone/sample-pack/calibration/strong/src/links.py
new file mode 100644
index 000000000..10358798b
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/strong/src/links.py
@@ -0,0 +1,6 @@
+def create_link(proposal_id: str) -> dict:
+ return {"id": proposal_id, "url": f"/p/{proposal_id}"}
+
+
+def record_visit(store: dict, link_id: str) -> None:
+ store[link_id] = store.get(link_id, 0) + 1
diff --git a/server/tests/fixtures/capstone/sample-pack/calibration/strong/tests/test_links.py b/server/tests/fixtures/capstone/sample-pack/calibration/strong/tests/test_links.py
new file mode 100644
index 000000000..afc7348f8
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/calibration/strong/tests/test_links.py
@@ -0,0 +1,7 @@
+from src.links import create_link, record_visit
+
+
+def test_creates_a_link_and_records_a_visit():
+ store = {}
+ record_visit(store, create_link("acme")["id"])
+ assert store["acme"] == 1
diff --git a/server/tests/fixtures/capstone/sample-pack/rubric.yaml b/server/tests/fixtures/capstone/sample-pack/rubric.yaml
new file mode 100644
index 000000000..887d100d4
--- /dev/null
+++ b/server/tests/fixtures/capstone/sample-pack/rubric.yaml
@@ -0,0 +1,48 @@
+# A small pack used by the capstone tests. Real packs live with their course in Edorax-core.
+pack: sample-site
+version: 1
+title: Personal site with tracked proposal links
+passPercent: 70
+submission:
+ repo: required
+ liveUrl: required
+automated:
+ - check: repo_public
+ - check: ci_green
+ - check: files
+ paths: [README.md]
+ - check: live_ok
+ - check: live_contains
+ text: data-analytics
+snapshot:
+ include: [README.md, "src/**", "tests/**"]
+criteria:
+ - id: proposal-links
+ title: Tracked proposal links
+ weight: 40
+ required: true
+ gate: [live_ok]
+ lookFor: Each proposal has its own URL and visits are recorded per link.
+ levels:
+ 0: No proposal links.
+ 1: Links exist but visits are not recorded.
+ 2: Links exist and every visit is recorded with the link it came from.
+ - id: readme
+ title: README a mentor can follow
+ weight: 20
+ passLevel: 1
+ lookFor: The README says what the site is, how to run it and how to deploy it.
+ levels:
+ 0: No README or only the template text.
+ 1: Says how to run it.
+ 2: Says how to run, test and deploy it.
+ - id: tests
+ title: Tests for the link tracker
+ weight: 40
+ required: true
+ gate: [ci_green]
+ lookFor: Tests cover creating a link and recording a visit.
+ levels:
+ 0: No tests.
+ 1: Tests exist but miss the visit recording.
+ 2: Tests cover both creating a link and recording a visit.
diff --git a/server/tests/unit/services/capstone/capstone.test.ts b/server/tests/unit/services/capstone/capstone.test.ts
new file mode 100644
index 000000000..ecf0bacf6
--- /dev/null
+++ b/server/tests/unit/services/capstone/capstone.test.ts
@@ -0,0 +1,489 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it, vi } from "vitest";
+import { RubricPackSchema, CapstoneSubmitSchema, capstoneClientConfig, type RubricPack } from "../../../../src/shared/blocks/capstone-check/schema.js";
+import type { AutomatedCheckResult } from "../../../../src/shared/blocks/capstone-check/report.js";
+import { globToRegExp, normaliseText, parseTemplateRef, runAutomatedChecks } from "../../../../src/services/capstone/automated.js";
+import { githubRepoSource, summariseCheckRun, summariseRuns, parseRepoUrl, type CapstoneRepoSource } from "../../../../src/services/capstone/github.js";
+import { anthropicGrader, buildGradePrompt, costMicroUsd, GraderError, recomputeReport, type ModelGrade } from "../../../../src/services/capstone/grade.js";
+import { calibrationDir, loadCalibrationExamples, loadRubricPackFile, parseRubricPack } from "../../../../src/services/capstone/pack-loader.js";
+import { assertFetchableUrl, isPublicAddress } from "../../../../src/services/capstone/safe-fetch.js";
+import { selectSnapshotPaths } from "../../../../src/services/capstone/snapshot.js";
+import { runCalibration } from "../../../../src/services/capstone/calibration.js";
+import { linkedInAddToProfileUrl, linkedInShareUrl, CertificateSettingsSchema, CERTIFICATE_CODE_PATTERN } from "../../../../src/shared/lib/certificates.js";
+import { newCertificateCode } from "../../../../src/services/certificates/code.js";
+import { sanitizeBlockConfig } from "../../../../src/utils/sanitize-block-config.js";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const PACK_DIR = path.resolve(here, "../../../fixtures/capstone/sample-pack");
+const COURSE_DIR = path.resolve(here, "../../../fixtures/capstone");
+
+function samplePack(): RubricPack {
+ return parseRubricPack(fs.readFileSync(path.join(PACK_DIR, "rubric.yaml"), "utf8"), "rubric.yaml");
+}
+
+const pass = (id: string, check: AutomatedCheckResult["check"]): AutomatedCheckResult => ({ id, check, status: "pass", message: "ok" });
+const allPass = (): AutomatedCheckResult[] => [
+ pass("repo_public", "repo_public"),
+ pass("ci_green", "ci_green"),
+ pass("files", "files"),
+ pass("live_ok", "live_ok"),
+ pass("live_contains", "live_contains"),
+];
+
+const grade = (scores: Record, extra: Partial = {}): ModelGrade => ({
+ summary: "Summary.",
+ criteria: Object.entries(scores).map(([id, score]) => ({ id, score, evidence: [`${id} evidence`], missing: [] })),
+ whatIsLeft: [],
+ qualityNotes: { strengths: ["Clear code."], improvements: ["Add tests."] },
+ confidence: "high",
+ integrityFlags: [],
+ ...extra,
+});
+
+describe("rubric packs", () => {
+ it("loads the sample pack with defaults", () => {
+ const loaded = samplePack();
+ expect(loaded.pack).toBe("sample-site");
+ expect(loaded.criteria).toHaveLength(3);
+ expect(loaded.oralExam.enabled).toBe(false);
+ expect(loaded.snapshot.maxFiles).toBe(60);
+ });
+
+ it("rejects a gate that names an unknown check", () => {
+ const raw = { pack: "x", version: 1, title: "X", automated: [{ check: "repo_public" }], criteria: [{ id: "a", title: "A", weight: 1, gate: ["ci_green"], lookFor: "a", levels: { "0": "no", "1": "yes" } }] };
+ const result = RubricPackSchema.safeParse(raw);
+ expect(result.success).toBe(false);
+ expect(JSON.stringify(result.error?.issues)).toContain('Unknown check id \\"ci_green\\"');
+ });
+
+ it("requires a repo_public check, unique check ids and a level 0", () => {
+ const base = { pack: "x", version: 1, title: "X", criteria: [{ id: "a", title: "A", weight: 1, lookFor: "a", levels: { "1": "yes" } }] };
+ const result = RubricPackSchema.safeParse({ ...base, automated: [{ check: "files", paths: ["a"] }, { check: "files", paths: ["b"] }] });
+ const text = JSON.stringify(result.error?.issues);
+ expect(text).toContain("Include a repo_public check");
+ expect(text).toContain("Duplicate check id");
+ expect(text).toContain("Describe level 0");
+ });
+
+ it("refuses live checks without a live URL, and the oral exam until it exists", () => {
+ const result = RubricPackSchema.safeParse({
+ pack: "x", version: 1, title: "X", automated: [{ check: "repo_public" }, { check: "live_ok" }], oralExam: { enabled: true },
+ criteria: [{ id: "a", title: "A", weight: 1, lookFor: "a", levels: { "0": "no", "1": "yes" } }],
+ });
+ const text = JSON.stringify(result.error?.issues);
+ expect(text).toContain("Live checks need submission.liveUrl");
+ expect(text).toContain("oral exam stage is not available yet");
+ });
+
+ it("names the file when a pack is missing or the path is wrong", () => {
+ expect(() => loadRubricPackFile(COURSE_DIR, "capstone/missing.yaml")).toThrow("capstone pack capstone/missing.yaml was not found");
+ expect(() => loadRubricPackFile(COURSE_DIR, "../secrets.yaml")).toThrow("must look like capstone/rubric.yaml");
+ });
+
+ it("keeps the rubric out of the reader config", () => {
+ const config = { pack: "capstone/rubric.yaml", rubric: samplePack() };
+ const client = capstoneClientConfig(config);
+ expect(client).toEqual({
+ title: "Personal site with tracked proposal links",
+ intro: null,
+ liveUrl: "required",
+ checklist: [
+ { id: "proposal-links", title: "Tracked proposal links", required: true },
+ { id: "readme", title: "README a mentor can follow", required: false },
+ { id: "tests", title: "Tests for the link tracker", required: true },
+ ],
+ });
+ expect(JSON.stringify(client)).not.toContain("lookFor");
+ // The generic sanitizer also strips `rubric` as a SECRET_KEY.
+ expect(sanitizeBlockConfig(config)).not.toHaveProperty("rubric");
+ });
+});
+
+describe("submission links", () => {
+ it("normalises GitHub repo links and refuses anything else", () => {
+ expect(CapstoneSubmitSchema.parse({ repoUrl: "https://github.com/me/site.git" }).repoUrl).toBe("https://github.com/me/site");
+ expect(CapstoneSubmitSchema.safeParse({ repoUrl: "https://gitlab.com/me/site" }).success).toBe(false);
+ expect(CapstoneSubmitSchema.safeParse({ repoUrl: "https://github.com/me/site/tree/main" }).success).toBe(false);
+ expect(CapstoneSubmitSchema.safeParse({ repoUrl: "https://github.com/me/site", liveUrl: "http://example.dev" }).success).toBe(false);
+ expect(parseRepoUrl("https://github.com/me/site")).toEqual({ owner: "me", repo: "site" });
+ });
+});
+
+describe("live URL guard", () => {
+ it.each([
+ ["127.0.0.1", false],
+ ["10.1.2.3", false],
+ ["172.20.0.1", false],
+ ["192.168.1.1", false],
+ ["169.254.169.254", false],
+ ["100.64.0.1", false],
+ ["0.0.0.0", false],
+ ["::1", false],
+ ["fd00::1", false],
+ ["fe80::1", false],
+ ["::ffff:10.0.0.1", false],
+ ["8.8.8.8", true],
+ ["2606:4700:4700::1111", true],
+ ])("%s public=%s", (ip, expected) => {
+ expect(isPublicAddress(ip)).toBe(expected);
+ });
+
+ it("refuses http, IP literals, internal names, other ports and credentials", () => {
+ expect(() => assertFetchableUrl("http://example.dev")).toThrow("Only https");
+ expect(() => assertFetchableUrl("https://127.0.0.1/")).toThrow("not an IP address");
+ expect(() => assertFetchableUrl("https://intranet/")).toThrow("not on the public internet");
+ expect(() => assertFetchableUrl("https://db.internal/")).toThrow("not on the public internet");
+ expect(() => assertFetchableUrl("https://example.dev:8443/")).toThrow("standard https port");
+ expect(() => assertFetchableUrl("https://a:b@example.dev/")).toThrow("username or password");
+ expect(assertFetchableUrl("https://example.dev/p/acme").hostname).toBe("example.dev");
+ });
+});
+
+describe("GitHub reads", () => {
+ it("reads files from the raw host so a check fits in the 60 an hour API limit without a token", async () => {
+ const calls: Array<{ url: string; auth: string | undefined }> = [];
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
+ calls.push({ url: String(url), auth: (init?.headers as Record | undefined)?.Authorization });
+ return new Response(String(url).includes("/commits/") ? JSON.stringify({ sha: "abc" }) : String(url).startsWith("https://api.github.com") ? JSON.stringify({ default_branch: "main", private: false }) : "hello", { status: 200 });
+ });
+ const source = githubRepoSource({ fetchImpl: fetchImpl as unknown as typeof fetch });
+ expect(await source.repo({ owner: "me", repo: "site" })).toEqual({ defaultBranch: "main", headSha: "abc", isPrivate: false });
+ expect(await source.file({ owner: "me", repo: "site" }, "abc", "src/a b.ts", 100)).toBe("hello");
+ expect(calls.map((c) => c.url)).toEqual([
+ "https://api.github.com/repos/me/site",
+ "https://api.github.com/repos/me/site/commits/main",
+ "https://raw.githubusercontent.com/me/site/abc/src/a%20b.ts",
+ ]);
+ expect(calls.every((c) => c.auth === undefined)).toBe(true);
+ });
+});
+
+describe("GitHub Actions summary", () => {
+ const run = (workflow_id: number, status: string, conclusion: string | null, created_at = "2026-11-06T10:00:00Z") => ({ name: `wf${workflow_id}`, workflow_id, status, conclusion, created_at });
+ it("uses the latest run of each workflow", () => {
+ expect(summariseRuns([])).toEqual({ state: "none" });
+ expect(summariseRuns([run(1, "completed", "failure", "2026-11-06T09:00:00Z"), run(1, "completed", "success")])).toEqual({ state: "success", runs: 1 });
+ expect(summariseRuns([run(1, "completed", "success"), run(2, "in_progress", null)])).toEqual({ state: "pending", running: ["wf2"] });
+ expect(summariseRuns([run(1, "completed", "success"), run(2, "completed", "failure")])).toEqual({ state: "failure", failed: ["wf2"] });
+ expect(summariseRuns([run(1, "completed", "skipped")])).toEqual({ state: "success", runs: 1 });
+ });
+});
+
+function fakeSource(overrides: Partial = {}): CapstoneRepoSource {
+ return {
+ repo: async () => ({ defaultBranch: "main", headSha: "abc123", isPrivate: false }),
+ tags: async () => ["v1.0.0"],
+ tree: async () => ({ entries: [{ path: "README.md", size: 10 }, { path: "src/links.ts", size: 100 }], truncated: false }),
+ file: async (_ref, _sha, filePath) => `contents of ${filePath}`,
+ ci: async () => ({ state: "success", runs: 2 }),
+ checkRun: async () => ({ state: "success" }),
+ ...overrides,
+ };
+}
+
+describe("automated checks", () => {
+ const ref = { owner: "me", repo: "site" };
+ const page = (body: string, ok = true) => vi.fn(async (url: string) => ({ ok, status: ok ? 200 : 500, finalUrl: url, body }));
+
+ it("passes a healthy submission and returns the live page text for the grader", async () => {
+ const run = await runAutomatedChecks(samplePack(), { ref, liveUrl: "https://example.dev" }, { source: fakeSource(), fetchPage: page('
Hello
') });
+ expect(run.results.map((r) => [r.id, r.status])).toEqual([
+ ["repo_public", "pass"],
+ ["ci_green", "pass"],
+ ["files", "pass"],
+ ["live_ok", "pass"],
+ ["live_contains", "pass"],
+ ]);
+ expect(run.stopped).toBe(false);
+ expect(run.repo?.commitSha).toBe("abc123");
+ expect(run.livePage?.text).toBe("Hello");
+ });
+
+ it("stops before the model when the repo is private or missing", async () => {
+ const run = await runAutomatedChecks(samplePack(), { ref, liveUrl: "https://example.dev" }, { source: fakeSource({ repo: async () => null }), fetchPage: page("") });
+ expect(run.stopped).toBe(true);
+ expect(run.results[0]!.status).toBe("fail");
+ expect(run.results.find((r) => r.id === "files")!.status).toBe("skipped");
+ });
+
+ it("stops while CI is still running and says to check again", async () => {
+ const run = await runAutomatedChecks(samplePack(), { ref, liveUrl: "https://example.dev" }, { source: fakeSource({ ci: async () => ({ state: "pending", running: ["test"] }) }), fetchPage: page("data-analytics") });
+ expect(run.stopped).toBe(true);
+ expect(run.results.find((r) => r.id === "ci_green")).toMatchObject({ status: "pending" });
+ });
+
+ it("fails a missing live URL when the pack requires one, and reports a missing file and snippet", async () => {
+ const missingLive = await runAutomatedChecks(samplePack(), { ref, liveUrl: null }, { source: fakeSource(), fetchPage: page("") });
+ expect(missingLive.results.find((r) => r.id === "live_ok")).toMatchObject({ status: "fail", message: "Add the live link to your submission." });
+
+ const noReadme = await runAutomatedChecks(samplePack(), { ref, liveUrl: "https://example.dev" }, {
+ source: fakeSource({ tree: async () => ({ entries: [{ path: "src/a.ts", size: 1 }], truncated: false }) }),
+ fetchPage: page("