Workflow Blueprint is an invite-gated Next.js App Router task planning workspace with admin-issued invitations, board-based task management, notes, profile settings, Resend-backed transactional email, and a key-authenticated external API (/api/external/v1/*) that other projects under the owner's control consume.
The live deployment is at https://www.workflowblueprint.io.
This repository is published as a showcase of a multi-agent development workflow with PR-level guardrails, not just as a working product. The artifacts of that workflow are checked in alongside the code:
- Strategic source of truth.
PROJECT.mddefines the product's purpose, non-goals, and the resolved open questions (Q1–Q6) that act as durable Verifier rules. Any PR that violates these rules is an automatic reject. - Tactical agent runbook.
AGENTS.mdis the operational quickstart that Builder agents (OpenAI Codex) read before writing code, plus dev-environment gotchas. - Walked-through case study.
CASE_STUDY.mdtraces a single PR (#13) end-to-end: the Builder prompt, the diff Codex returned, the Verifier rule it triggered (the Q6 scope-discipline rule), and how the rule itself was born from that PR. - Machine-readable API contract with CI drift detection.
docs/openapi.yamlis generated from Zod schemas insrc/lib/external-contract.ts; a CI test (tests/api/external/openapi.test.ts) fails any PR where the committed spec diverges from the schemas. - Real CI gates, not vibes.
.github/workflows/ci.ymlruns three parallel jobs (lint,test,smoke) on every PR, with a Postgres service container backing the integration and smoke suites.
The most informative entry points are PROJECT.md, the merged PR history (especially #7, #10, #13, and #14), and CASE_STUDY.md.
A short, honest retrospective. Three things I would change if I were starting this repo over today:
- Write
PROJECT.mdon day one, not at PR 5. The Builder/Verifier handoff was installed in#5after several feature PRs had already merged. Several of those earlier PRs would have been smaller and more focused if the scope-discipline rule (Q6) had existed when they were prompted. Lesson: the strategic document should be the first commit, even when its contents are still rough. - Adopt path-versioned external APIs from the first endpoint. The original API lived at
/api/external/daily-summaryand/api/read-only/*. Migrating to/api/external/v1/*required PR 3 (consumer migration in another repo) and PR 4 (legacy alias removal) before the contract could be cleanly versioned. Starting with/v1/from day one would have eliminated both PRs. - Treat the Builder prompt as a reviewable artifact. Q6 ("out-of-scope changes must be declared in the PR body") only became enforceable after
#13shipped a correct-but-unauthorized SQL rewrite. If the Builder prompt itself were checked into the PR description from the start, scope drift would be auditable from day one rather than caught reactively.
- Next.js 16 App Router and React 19
- Prisma 6 with PostgreSQL persistence (currently hosted on Supabase)
- Tailwind CSS 4 with custom blueprint design tokens
- Zod validation on all API payloads
- Signed HTTP-only session cookies with
jose - Resend transactional email for welcome and password reset messages
npm install
npm run db:deploy
npm run db:seed
npm run devThe dev server starts Next.js on 127.0.0.1. Run npm run db:deploy before the first deploy, and run npm run db:seed only when you want the demo account and starter boards in the configured database.
The seed command reads the demo account password from the required DEMO_USER_PASSWORD environment variable and refuses to run when NODE_ENV=production or VERCEL_ENV=production unless ALLOW_PRODUCTION_SEED=true is also set. Choose a unique value per environment and rotate it.
DEMO_USER_PASSWORD="choose-a-strong-password-of-12-or-more-chars"
npm run db:seed
Create .env.local for local work:
DATABASE_URL="postgresql://postgres:[password]@db.[project-ref].supabase.co:5432/postgres?sslmode=require"
AUTH_SECRET="replace-with-a-long-random-secret"
NEXT_PUBLIC_SITE_URL="https://www.workflowblueprint.io"
RESEND_API_KEY="re_..."
EMAIL_FROM="Workflow Blueprint <hello@workflowblueprint.io>"
EXTERNAL_API_KEY="replace-with-the-shared-external-api-key"
EXTERNAL_USER_ID="user_demo_alex_blue"When the project is linked in Vercel, you can pull local secrets without printing them:
npx vercel@latest env pull .env.local --environment=developmentDATABASE_URL must be a PostgreSQL 14+ connection string. Supabase Postgres is the recommended example and current production host, but any compatible durable PostgreSQL database works. If the Vercel/Supabase integration provides POSTGRES_PRISMA_URL, POSTGRES_URL, or POSTGRES_URL_NON_POOLING instead, the app will use those automatically.
Prisma CLI commands prefer POSTGRES_URL_NON_POOLING when it is available.
Use a durable PostgreSQL 14+ database for production account creation.
AUTH_SECRET must be a long random secret in production.
NEXT_PUBLIC_SITE_URL is used to generate absolute canonical and social sharing metadata.
RESEND_API_KEY and EMAIL_FROM enable welcome emails and production password reset emails. Local development can omit them; reset requests will expose a preview link instead.
EXTERNAL_API_KEY enables the external /api/external/v1/* API. EXTERNAL_USER_ID selects which account the external API surfaces; when unset it falls back to the seeded demo user.
Optional server-side Sentry settings:
| Variable | Required | Description |
|---|---|---|
SENTRY_DSN |
No | Enables Sentry server-side error capture when set. Leave unset for local dev. |
SENTRY_ENVIRONMENT |
No | Override for the Sentry environment tag. Defaults to VERCEL_ENV or "development". |
SENTRY_RELEASE |
No | Override for the Sentry release tag. Defaults to VERCEL_GIT_COMMIT_SHA. |
Apply the checked-in Prisma migrations to the database before enabling signup:
npm run db:deployFor a brand-new database, optionally seed the demo account:
npm run db:seedIf the database runtime URL uses a pooler and migration deployment fails, temporarily run npm run db:deploy with the direct connection string in DATABASE_URL, then keep the Vercel runtime DATABASE_URL pointed at the connection string you use for serverless traffic.
The external API exposes the configured user's planning data for project-owned consumers. Canonical endpoints live under /api/external/v1/*.
The authoritative machine-readable API reference is docs/openapi.yaml. The examples below are a human-readable summary of that contract.
Every response from /api/external/v1/* includes an X-Request-Id header (UUID v4) for log correlation. The same ID is written to a structured JSON log line on the server alongside the request route, status, duration, outcome, and a non-sensitive 8-character prefix of the API key used. Consumers may capture this header to trace client-side errors back to server logs.
Server-side errors are also captured to Sentry when the SENTRY_DSN environment variable is set. Captured events include requestId, route, and outcome tags so they can be correlated with the structured log lines emitted by the external API wrapper. Authorization headers are stripped before any event leaves the server.
Every response also includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (Unix epoch seconds) so consumers can self-throttle. When the limit is exceeded, the API returns 429 with the standard Retry-After header.
Every v1 response is JSON, dynamic (force-dynamic, revalidate = 0), and sent with Cache-Control: no-store and X-Robots-Tag: noindex.
Every canonical v1 request must include the configured external key:
Authorization: Bearer <EXTERNAL_API_KEY>Keys are compared with SHA-256 + timingSafeEqual. All external routes require EXTERNAL_API_KEY to be set.
- Missing or malformed
Authorizationheader →401JSON. - Wrong key →
403JSON. - Required key is unset →
503JSON.
Most external API errors use this shape:
type ExternalApiError = {
ok: false;
error: string;
};| Method | Path | Description |
|---|---|---|
GET |
/api/external/v1/dashboard |
Aggregate dashboard payload |
GET |
/api/external/v1/boards |
All boards owned by the configured external user |
GET |
/api/external/v1/boards/[slug] |
One board by slug, including tasks, subtasks, and note content |
GET |
/api/external/v1/daily-summary |
Daily briefing payload used by external automation |
Request:
curl -i \
-H "Authorization: Bearer $EXTERNAL_API_KEY" \
https://www.workflowblueprint.io/api/external/v1/dashboardResponse:
type ExternalDashboardResponse = {
ok: true;
data: {
boardBreakdown: Array<{
slug: string;
name: string;
iconKey: string;
totalTasks: number;
percentage: number;
}>;
sprintCompletionRate: number;
doneCount: number;
activeTaskCount: number;
inProgressCount: number;
closedLastSevenDays: number;
totalTaskCount: number;
};
};Request:
curl -i \
-H "Authorization: Bearer $EXTERNAL_API_KEY" \
https://www.workflowblueprint.io/api/external/v1/boardsResponse:
type ExternalBoardsResponse = {
ok: true;
data: {
boards: Array<{
slug: string;
name: string;
description: string | null;
iconKey: string;
totalTasks: number;
}>;
};
};Request:
curl -i \
-H "Authorization: Bearer $EXTERNAL_API_KEY" \
https://www.workflowblueprint.io/api/external/v1/boards/personalResponse:
type ExternalBoardResponse = {
ok: true;
data: {
id: string;
slug: string;
name: string;
description: string | null;
iconKey: string;
noteContent: string;
tasks: Array<{
id: string;
title: string;
description: string | null;
status: "ICE_BOX" | "ON_DECK" | "IN_PROGRESS" | "DONE" | "ARCHIVED";
sortOrder: number;
priority: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "URGENT";
dueDate: string | null;
completedAt: string | null;
archivedAt: string | null;
subtasks: Array<{
id: string;
title: string;
isComplete: boolean;
sortOrder: number;
priority: "NONE" | "LOW" | "MEDIUM" | "HIGH" | "URGENT";
}>;
}>;
};
};Request:
curl -i \
-H "Authorization: Bearer $EXTERNAL_API_KEY" \
https://www.workflowblueprint.io/api/external/v1/daily-summaryResponse:
type ExternalDailySummaryResponse = {
generatedAt: string;
summary: {
totalActive: number;
completionRate: `${number}%`;
byStatus: {
iceBox: number;
onDeck: number;
inProgress: number;
done: number;
archived: number;
};
byCategory: Record<string, number>;
};
inProgress: ExternalDailySummaryTask[];
onDeck: ExternalDailySummaryTask[];
iceBox: ExternalDailySummaryTask[];
recentlyCompleted: ExternalDailySummaryTask[];
};
type ExternalDailySummaryTask = {
id: number;
title: string;
description: string | null;
status: "ice-box" | "on-deck" | "in-progress" | "done" | "archived";
category: string;
priority: "none" | "low" | "medium" | "high" | "urgent";
parentId: number | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
};Daily-summary task ids are stable 48-bit hashes of the underlying UUIDs. summary.byCategory uses camelCase board slugs as keys.
npm run dev # start the local Next.js server
npm run build # local production build and type check (no migrations)
npm run vercel-build # Vercel uses this: applies Prisma migrations, then builds
npm run lint # ESLint / Next core web vitals checks
npm run db:deploy # apply checked-in Prisma migrations
npm run db:migrate # create and apply a development migration
npm run db:push # push schema directly for non-migration development
npm run db:seed # seed the demo account and boardsVercel automatically runs vercel-build instead of build when it is present, so each production deployment applies any pending Prisma migrations before the new code starts handling requests. Local npm run build deliberately does not migrate so it cannot accidentally touch a remote database.
This project is licensed under the PolyForm Noncommercial License 1.0.0.
This is a source-available license that permits personal use, research, and non-commercial projects. Commercial use is strictly prohibited without express written permission from Roy McFarland.
See the LICENSE file for the full text.
- API routes use shared JSON parsing and Zod schema validation helpers.
- External API responses are validated before being returned.
- Authenticated API routes return JSON
401responses instead of page redirects. - Sign-up, sign-in, password reset, invitation, and external API endpoints share a Postgres-backed distributed rate limiter (
RateLimitBuckettable) so limits hold across serverless instances. - Mutating routes verify the request
Origin/ReferermatchesNEXT_PUBLIC_SITE_URLand the session cookie isSameSite=strict, providing a CSRF defense. - HTML responses get a per-request nonce-based Content Security Policy (
'strict-dynamic'); API and static responses get a stricter baseline CSP. - Session JWTs include the user's
passwordChangedAttimestamp so password changes/resets revoke every existing session. - Password reset and invitation tokens are stored hashed and claimed atomically inside transactions before any state changes.
- Development reset links are returned only outside production; production sends reset and invitation links through Resend.
- Admin actions (invitation create/revoke, role promotion) write an
AdminAuditLogrow recording actor, action, target, and timestamp.