@forgeailab/create-spark 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -4
- package/packs/README.md +132 -0
- package/packs/ai-anthropic/files/app/api/ai/route.ts +57 -0
- package/packs/ai-anthropic/files/lib/anthropic.ts +15 -0
- package/packs/ai-anthropic/pack.toml +32 -0
- package/packs/ai-anthropic/skills/ai-feature-patterns/SKILL.md +87 -0
- package/packs/ai-anthropic/tasks.yaml +9 -0
- package/packs/ai-openai/files/app/api/ai-openai/route.ts +55 -0
- package/packs/ai-openai/files/lib/openai.ts +21 -0
- package/packs/ai-openai/pack.toml +30 -0
- package/packs/ai-openai/tasks.yaml +9 -0
- package/packs/analytics-posthog/files/components/PostHogProvider.tsx +19 -0
- package/packs/analytics-posthog/files/lib/posthog/client.ts +20 -0
- package/packs/analytics-posthog/files/lib/posthog/server.ts +24 -0
- package/packs/analytics-posthog/pack.toml +35 -0
- package/packs/analytics-posthog/tasks.yaml +15 -0
- package/packs/auth-better-auth/files/app/(auth)/login/page.tsx +58 -0
- package/packs/auth-better-auth/files/app/api/auth/[...all]/route.ts +4 -0
- package/packs/auth-better-auth/files/lib/auth.ts +21 -0
- package/packs/auth-better-auth/pack.toml +32 -0
- package/packs/auth-better-auth/tasks.yaml +10 -0
- package/packs/auth-better-auth-pg/files/app/api/auth/[...all]/route.ts +4 -0
- package/packs/auth-better-auth-pg/files/lib/auth.ts +86 -0
- package/packs/auth-better-auth-pg/pack.toml +32 -0
- package/packs/auth-better-auth-pg/tasks.yaml +17 -0
- package/packs/auth-supabase/files/app/(auth)/login/page.tsx +64 -0
- package/packs/auth-supabase/files/app/auth/callback/route.ts +15 -0
- package/packs/auth-supabase/files/middleware.ts +41 -0
- package/packs/auth-supabase/pack.toml +34 -0
- package/packs/auth-supabase/tasks.yaml +10 -0
- package/packs/db-postgres/files/compose/postgres.yml +28 -0
- package/packs/db-postgres/files/docker-compose.include.yml +1 -0
- package/packs/db-postgres/files/docker-compose.yml +6 -0
- package/packs/db-postgres/files/drizzle.config.ts +10 -0
- package/packs/db-postgres/files/lib/db/index.ts +10 -0
- package/packs/db-postgres/files/lib/db/schema.ts +11 -0
- package/packs/db-postgres/pack.toml +53 -0
- package/packs/db-postgres/tasks.yaml +11 -0
- package/packs/db-sqlite/files/drizzle.config.ts +10 -0
- package/packs/db-sqlite/files/lib/db.ts +8 -0
- package/packs/db-sqlite/files/lib/schema.ts +13 -0
- package/packs/db-sqlite/pack.toml +34 -0
- package/packs/db-sqlite/tasks.yaml +6 -0
- package/packs/db-supabase/files/lib/supabase/client.ts +8 -0
- package/packs/db-supabase/files/lib/supabase/server.ts +27 -0
- package/packs/db-supabase/pack.toml +32 -0
- package/packs/db-supabase/skills/supabase-patterns/SKILL.md +82 -0
- package/packs/db-supabase/tasks.yaml +6 -0
- package/packs/deploy-vercel/files/docs/deploy.md +21 -0
- package/packs/deploy-vercel/files/vercel.json +4 -0
- package/packs/deploy-vercel/pack.toml +30 -0
- package/packs/deploy-vercel/tasks.yaml +14 -0
- package/packs/docker-compose-dev/files/.env.docker.example +2 -0
- package/packs/docker-compose-dev/files/compose/redis.yml +17 -0
- package/packs/docker-compose-dev/files/docker-compose.include.yml +1 -0
- package/packs/docker-compose-dev/files/docker-compose.yml +6 -0
- package/packs/docker-compose-dev/pack.toml +38 -0
- package/packs/docker-compose-dev/tasks.yaml +9 -0
- package/packs/email-resend/files/app/api/email/test/route.ts +38 -0
- package/packs/email-resend/files/emails/welcome.tsx +66 -0
- package/packs/email-resend/files/lib/email.ts +40 -0
- package/packs/email-resend/pack.toml +34 -0
- package/packs/email-resend/tasks.yaml +9 -0
- package/packs/example/pack.toml +69 -0
- package/packs/payments-stripe/files/app/api/billing-portal/route.ts +24 -0
- package/packs/payments-stripe/files/app/api/checkout/route.ts +58 -0
- package/packs/payments-stripe/files/app/api/webhooks/stripe/route.ts +84 -0
- package/packs/payments-stripe/files/lib/stripe.ts +60 -0
- package/packs/payments-stripe/pack.toml +49 -0
- package/packs/payments-stripe/skills/stripe-patterns/SKILL.md +93 -0
- package/packs/payments-stripe/tasks.yaml +16 -0
- package/packs/sync-zero/files/components/ZeroProvider.tsx +3 -0
- package/packs/sync-zero/files/compose/zero-cache.yml +26 -0
- package/packs/sync-zero/files/docker-compose.include.yml +1 -0
- package/packs/sync-zero/files/docker-compose.yml +6 -0
- package/packs/sync-zero/files/lib/zero/client.ts +18 -0
- package/packs/sync-zero/files/lib/zero/schema.ts +17 -0
- package/packs/sync-zero/files/zero.config.ts +26 -0
- package/packs/sync-zero/pack.toml +61 -0
- package/packs/sync-zero/skills/zero-patterns/SKILL.md +69 -0
- package/packs/sync-zero/tasks.yaml +16 -0
- package/packs/testing-playwright/files/e2e/example.spec.ts +7 -0
- package/packs/testing-playwright/files/playwright.config.ts +33 -0
- package/packs/testing-playwright/pack.toml +25 -0
- package/packs/testing-playwright/tasks.yaml +9 -0
- package/packs/ui-shadcn/files/app/globals.css +56 -0
- package/packs/ui-shadcn/files/components/ui/button.tsx +47 -0
- package/packs/ui-shadcn/files/components/ui/card.tsx +33 -0
- package/packs/ui-shadcn/files/lib/utils.ts +6 -0
- package/packs/ui-shadcn/files/postcss.config.mjs +7 -0
- package/packs/ui-shadcn/files/tailwind.config.ts +57 -0
- package/packs/ui-shadcn/pack.toml +44 -0
- package/packs/ui-shadcn/skills/shadcn-dashboard-patterns/SKILL.md +85 -0
- package/packs/ui-shadcn/tasks.yaml +6 -0
- package/presets/docs-site.toml +4 -0
- package/presets/internal-tool.toml +4 -0
- package/presets/lean-saas.toml +4 -0
- package/presets/local-ai-mvp.toml +4 -0
- package/presets/saas-classic.toml +4 -0
- package/src/paths.ts +22 -4
- package/templates/README.md +43 -0
- package/templates/astro/README.md +3 -0
- package/templates/astro/template.toml +4 -0
- package/templates/astro-starlight/README.md +3 -0
- package/templates/astro-starlight/template.toml +4 -0
- package/templates/nextjs/.ai/architecture.md +13 -0
- package/templates/nextjs/.ai/board.md +7 -0
- package/templates/nextjs/.ai/product-spec.md +11 -0
- package/templates/nextjs/.claude/skills/.gitkeep +0 -0
- package/templates/nextjs/.codex/skills/.gitkeep +0 -0
- package/templates/nextjs/AGENTS.md +95 -0
- package/templates/nextjs/CLAUDE.md +3 -0
- package/templates/nextjs/README.md +20 -0
- package/templates/nextjs/anvil.config.json +4 -0
- package/templates/nextjs/app/(app)/home/page.tsx +43 -0
- package/templates/nextjs/app/(app)/home/posts-panel.tsx +83 -0
- package/templates/nextjs/app/(app)/layout.tsx +12 -0
- package/templates/nextjs/app/(auth)/login/page.tsx +97 -0
- package/templates/nextjs/app/globals.css +23 -0
- package/templates/nextjs/app/layout.tsx +20 -0
- package/templates/nextjs/app/page.tsx +39 -0
- package/templates/nextjs/lib/auth-placeholder.ts +21 -0
- package/templates/nextjs/lib/posts-placeholder.ts +30 -0
- package/templates/nextjs/next.config.ts +5 -0
- package/templates/nextjs/package.json +26 -0
- package/templates/nextjs/postcss.config.mjs +7 -0
- package/templates/nextjs/template.toml +4 -0
- package/templates/nextjs/tsconfig.json +27 -0
- package/templates/nextjs/types/post.ts +13 -0
- package/templates/one/README.md +5 -0
- package/templates/one/template.toml +4 -0
- package/templates/vite-react/README.md +3 -0
- package/templates/vite-react/template.toml +4 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: supabase-patterns
|
|
3
|
+
description: Apply Supabase client, server, and RLS patterns in Next.js apps without leaking authorization into the browser.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: supabase-patterns
|
|
7
|
+
|
|
8
|
+
## Goal
|
|
9
|
+
|
|
10
|
+
Use Supabase as the database boundary for a Next.js app while keeping data
|
|
11
|
+
access explicit, RLS-backed, and easy to review. Client code may improve user
|
|
12
|
+
experience, but database authorization belongs in Postgres policies.
|
|
13
|
+
|
|
14
|
+
## Client Choice
|
|
15
|
+
|
|
16
|
+
- Use the browser client only inside Client Components and browser utilities.
|
|
17
|
+
- Use the server client inside Server Components, Server Actions, and Route Handlers.
|
|
18
|
+
- Use the service role key only in server-only code.
|
|
19
|
+
- Never expose the service role key through `NEXT_PUBLIC_` variables.
|
|
20
|
+
- Keep client creation in `lib/supabase/client.ts` and `lib/supabase/server.ts`.
|
|
21
|
+
- Import those helpers instead of constructing clients throughout the app.
|
|
22
|
+
|
|
23
|
+
## RLS Baseline
|
|
24
|
+
|
|
25
|
+
- Enable RLS on every user-facing table before shipping.
|
|
26
|
+
- Write one policy per action: select, insert, update, delete.
|
|
27
|
+
- Start restrictive, then add policies for real workflows.
|
|
28
|
+
- Prefer `auth.uid()` ownership checks for user-owned rows.
|
|
29
|
+
- Prefer membership tables for team or organization access.
|
|
30
|
+
- Avoid policies that only check whether a user is signed in.
|
|
31
|
+
- Avoid policies that mirror UI visibility without enforcing ownership.
|
|
32
|
+
|
|
33
|
+
## Schema Patterns
|
|
34
|
+
|
|
35
|
+
- Add `user_id uuid references auth.users(id)` for personal records.
|
|
36
|
+
- Add `organization_id` plus a membership table for multi-tenant data.
|
|
37
|
+
- Use `created_at` and `updated_at` consistently.
|
|
38
|
+
- Add indexes for policy predicates such as `user_id` and `organization_id`.
|
|
39
|
+
- Keep public profile data separate from private account data.
|
|
40
|
+
- Do not join to private tables from public views unless the policy is clear.
|
|
41
|
+
|
|
42
|
+
## Server Reads And Writes
|
|
43
|
+
|
|
44
|
+
- Prefer server reads when data is needed for initial page render.
|
|
45
|
+
- Put writes in Server Actions or Route Handlers when they affect trusted state.
|
|
46
|
+
- Re-check authorization in SQL policies, even when the server action checks it.
|
|
47
|
+
- Use `select()` projections instead of fetching whole rows by default.
|
|
48
|
+
- Return typed view models to Client Components.
|
|
49
|
+
- Avoid passing raw Supabase errors directly into user-facing copy.
|
|
50
|
+
|
|
51
|
+
## Browser Reads
|
|
52
|
+
|
|
53
|
+
- Browser reads are fine for realtime lists, optimistic UI, and user-owned data.
|
|
54
|
+
- Keep filters aligned with RLS policies so results are predictable.
|
|
55
|
+
- Treat browser filters as performance hints, not authorization.
|
|
56
|
+
- Use loading, empty, and error states for every browser query.
|
|
57
|
+
- Do not fetch admin data from the browser, even behind hidden UI.
|
|
58
|
+
|
|
59
|
+
## Auth And Cookies
|
|
60
|
+
|
|
61
|
+
- Middleware should refresh auth cookies before protected routes read sessions.
|
|
62
|
+
- Use `getUser()` or claims validation on the server before trusted actions.
|
|
63
|
+
- Avoid trusting session data that only came from local storage.
|
|
64
|
+
- Redirect unauthenticated users at route boundaries.
|
|
65
|
+
- Keep callback routes small: exchange the code, then redirect.
|
|
66
|
+
|
|
67
|
+
## Service Role Use
|
|
68
|
+
|
|
69
|
+
- Reserve the service role for background jobs and admin workflows.
|
|
70
|
+
- Create a separate helper for service-role clients.
|
|
71
|
+
- Keep service-role operations narrow and logged.
|
|
72
|
+
- Never use the service role to bypass missing user policies in normal flows.
|
|
73
|
+
- If a user action needs service role access, reconsider the table design.
|
|
74
|
+
|
|
75
|
+
## Review Checklist
|
|
76
|
+
|
|
77
|
+
- Every exposed table has RLS enabled.
|
|
78
|
+
- Every policy has a named workflow it supports.
|
|
79
|
+
- Server-only keys are not imported by Client Components.
|
|
80
|
+
- Client queries cannot reveal another tenant's data.
|
|
81
|
+
- Seeded sample data matches the policies.
|
|
82
|
+
- Error states do not expose table names or policy details.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Vercel Deploy Checklist
|
|
2
|
+
|
|
3
|
+
Use this checklist after installing the `deploy-vercel` pack.
|
|
4
|
+
|
|
5
|
+
## Project setup
|
|
6
|
+
|
|
7
|
+
- Create or link the Vercel project.
|
|
8
|
+
- Confirm the project framework preset is `Next.js`.
|
|
9
|
+
- Set `VERCEL_PROJECT_ID` and `VERCEL_ORG_ID` locally when using Vercel CLI automation.
|
|
10
|
+
|
|
11
|
+
## Environment variables
|
|
12
|
+
|
|
13
|
+
- Add every required pack environment variable to Vercel.
|
|
14
|
+
- Keep preview and production values separate when credentials differ.
|
|
15
|
+
- Redeploy after changing environment variables.
|
|
16
|
+
|
|
17
|
+
## Preview deploys
|
|
18
|
+
|
|
19
|
+
- Enable pull request preview deploys.
|
|
20
|
+
- Confirm preview deploys run against non-production services.
|
|
21
|
+
- Keep production-only credentials out of preview environments.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name = "deploy-vercel"
|
|
2
|
+
version = "1.0.0"
|
|
3
|
+
category = "deploy"
|
|
4
|
+
description = "Vercel deployment target for Next.js apps."
|
|
5
|
+
provides = ["deploy-target"]
|
|
6
|
+
requires = []
|
|
7
|
+
conflicts = []
|
|
8
|
+
requires_runtime = []
|
|
9
|
+
compatible_scaffolds = ["nextjs"]
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
runtime = []
|
|
13
|
+
dev = []
|
|
14
|
+
|
|
15
|
+
[env]
|
|
16
|
+
required = []
|
|
17
|
+
optional = ["VERCEL_PROJECT_ID", "VERCEL_ORG_ID"]
|
|
18
|
+
|
|
19
|
+
[[files]]
|
|
20
|
+
mode = "create"
|
|
21
|
+
from = "vercel.json"
|
|
22
|
+
to = "vercel.json"
|
|
23
|
+
|
|
24
|
+
[[files]]
|
|
25
|
+
mode = "create"
|
|
26
|
+
from = "docs/deploy.md"
|
|
27
|
+
to = "docs/deploy.md"
|
|
28
|
+
|
|
29
|
+
[tasks]
|
|
30
|
+
file = "tasks.yaml"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
epic: Deploy
|
|
2
|
+
tasks:
|
|
3
|
+
- id: DEPLOY-001
|
|
4
|
+
title: Configure environment variables in Vercel dashboard
|
|
5
|
+
status: Clarifying
|
|
6
|
+
acceptance:
|
|
7
|
+
- Required pack environment variables are present in the Vercel project.
|
|
8
|
+
- Preview and production values are documented when they differ.
|
|
9
|
+
- id: DEPLOY-002
|
|
10
|
+
title: Set up preview deploys for pull requests
|
|
11
|
+
status: Clarifying
|
|
12
|
+
acceptance:
|
|
13
|
+
- Pull requests create Vercel preview deployments.
|
|
14
|
+
- Preview deployments use non-production credentials where applicable.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
services:
|
|
2
|
+
redis:
|
|
3
|
+
image: redis:7-alpine
|
|
4
|
+
restart: unless-stopped
|
|
5
|
+
command: ["redis-server", "--appendonly", "yes"]
|
|
6
|
+
ports:
|
|
7
|
+
- "${REDIS_PORT:-6379}:6379"
|
|
8
|
+
volumes:
|
|
9
|
+
- redis_data:/data
|
|
10
|
+
healthcheck:
|
|
11
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
12
|
+
interval: 10s
|
|
13
|
+
timeout: 5s
|
|
14
|
+
retries: 5
|
|
15
|
+
|
|
16
|
+
volumes:
|
|
17
|
+
redis_data:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- compose/redis.yml
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name = "docker-compose-dev"
|
|
2
|
+
version = "1.0.0"
|
|
3
|
+
category = "infra"
|
|
4
|
+
description = "Local Redis for development via Docker Compose. Composes with db-postgres / sync-zero."
|
|
5
|
+
provides = ["local-runtime"]
|
|
6
|
+
requires = []
|
|
7
|
+
conflicts = []
|
|
8
|
+
requires_runtime = []
|
|
9
|
+
compatible_scaffolds = []
|
|
10
|
+
|
|
11
|
+
[env]
|
|
12
|
+
optional = ["REDIS_PORT"]
|
|
13
|
+
|
|
14
|
+
# Compose root + redis fragment. Other infra-providing packs (db-postgres,
|
|
15
|
+
# sync-zero) follow the same pattern: each uses `create-or-skip` to bootstrap
|
|
16
|
+
# the root file, then `append` to add their include line under a marker.
|
|
17
|
+
[[files]]
|
|
18
|
+
mode = "create-or-skip"
|
|
19
|
+
from = "docker-compose.yml"
|
|
20
|
+
to = "docker-compose.yml"
|
|
21
|
+
|
|
22
|
+
[[files]]
|
|
23
|
+
mode = "create"
|
|
24
|
+
from = "compose/redis.yml"
|
|
25
|
+
to = "compose/redis.yml"
|
|
26
|
+
|
|
27
|
+
[[files]]
|
|
28
|
+
mode = "append"
|
|
29
|
+
from = "docker-compose.include.yml"
|
|
30
|
+
to = "docker-compose.yml"
|
|
31
|
+
|
|
32
|
+
[[files]]
|
|
33
|
+
mode = "create"
|
|
34
|
+
from = ".env.docker.example"
|
|
35
|
+
to = ".env.docker.example"
|
|
36
|
+
|
|
37
|
+
[tasks]
|
|
38
|
+
file = "tasks.yaml"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
epic: Infrastructure
|
|
2
|
+
|
|
3
|
+
tasks:
|
|
4
|
+
- id: INFRA-001
|
|
5
|
+
title: Run docker compose up -d and verify Postgres reachable on port 5432
|
|
6
|
+
status: Clarifying
|
|
7
|
+
acceptance:
|
|
8
|
+
- docker compose up -d starts Postgres and Redis containers.
|
|
9
|
+
- Postgres accepts connections on the configured local port.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import WelcomeEmail from "@/emails/welcome";
|
|
2
|
+
import { sendEmail } from "@/lib/email";
|
|
3
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
4
|
+
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
type TestEmailRequest = {
|
|
8
|
+
to?: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
productName?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function POST(request: NextRequest) {
|
|
14
|
+
if (process.env.NODE_ENV !== "development") {
|
|
15
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (request.headers.get("x-spark-dev-email") !== "true") {
|
|
19
|
+
return NextResponse.json({ error: "Dev email flag is required" }, { status: 403 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const body = (await request.json().catch(() => ({}))) as TestEmailRequest;
|
|
23
|
+
|
|
24
|
+
if (!body.to) {
|
|
25
|
+
return NextResponse.json({ error: "to is required" }, { status: 400 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result = await sendEmail({
|
|
29
|
+
to: body.to,
|
|
30
|
+
subject: `Welcome to ${body.productName ?? "the app"}`,
|
|
31
|
+
react: WelcomeEmail({
|
|
32
|
+
name: body.name,
|
|
33
|
+
productName: body.productName,
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return NextResponse.json({ id: result.data?.id });
|
|
38
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Container,
|
|
4
|
+
Head,
|
|
5
|
+
Heading,
|
|
6
|
+
Html,
|
|
7
|
+
Preview,
|
|
8
|
+
Section,
|
|
9
|
+
Text,
|
|
10
|
+
} from "@react-email/components";
|
|
11
|
+
|
|
12
|
+
export type WelcomeEmailProps = {
|
|
13
|
+
name?: string;
|
|
14
|
+
productName?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default function WelcomeEmail({
|
|
18
|
+
name = "there",
|
|
19
|
+
productName = "the app",
|
|
20
|
+
}: WelcomeEmailProps) {
|
|
21
|
+
return (
|
|
22
|
+
<Html>
|
|
23
|
+
<Head />
|
|
24
|
+
<Preview>Welcome to {productName}</Preview>
|
|
25
|
+
<Body style={body}>
|
|
26
|
+
<Container style={container}>
|
|
27
|
+
<Section>
|
|
28
|
+
<Heading style={heading}>Welcome, {name}</Heading>
|
|
29
|
+
<Text style={paragraph}>
|
|
30
|
+
Your account is ready. You can now continue setting up {productName}.
|
|
31
|
+
</Text>
|
|
32
|
+
<Text style={paragraph}>
|
|
33
|
+
Keep this email as a simple delivery check while the product email
|
|
34
|
+
system is being wired up.
|
|
35
|
+
</Text>
|
|
36
|
+
</Section>
|
|
37
|
+
</Container>
|
|
38
|
+
</Body>
|
|
39
|
+
</Html>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const body = {
|
|
44
|
+
backgroundColor: "#f6f7f9",
|
|
45
|
+
color: "#111827",
|
|
46
|
+
fontFamily: "Arial, sans-serif",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const container = {
|
|
50
|
+
backgroundColor: "#ffffff",
|
|
51
|
+
margin: "40px auto",
|
|
52
|
+
padding: "32px",
|
|
53
|
+
maxWidth: "560px",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const heading = {
|
|
57
|
+
fontSize: "24px",
|
|
58
|
+
lineHeight: "32px",
|
|
59
|
+
margin: "0 0 16px",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const paragraph = {
|
|
63
|
+
fontSize: "16px",
|
|
64
|
+
lineHeight: "24px",
|
|
65
|
+
margin: "0 0 16px",
|
|
66
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Resend } from "resend";
|
|
3
|
+
|
|
4
|
+
function requireEnv(name: string): string {
|
|
5
|
+
const value = process.env[name];
|
|
6
|
+
|
|
7
|
+
if (!value) {
|
|
8
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const resend = new Resend(requireEnv("RESEND_API_KEY"));
|
|
15
|
+
|
|
16
|
+
export type SendEmailInput = {
|
|
17
|
+
to: string | string[];
|
|
18
|
+
subject: string;
|
|
19
|
+
from?: string;
|
|
20
|
+
replyTo?: string | string[];
|
|
21
|
+
react?: ReactElement;
|
|
22
|
+
html?: string;
|
|
23
|
+
text?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function sendEmail(input: SendEmailInput) {
|
|
27
|
+
if (!input.react && !input.html && !input.text) {
|
|
28
|
+
throw new Error("sendEmail requires react, html, or text content");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return resend.emails.send({
|
|
32
|
+
from: input.from ?? process.env.RESEND_FROM ?? "App <onboarding@resend.dev>",
|
|
33
|
+
to: input.to,
|
|
34
|
+
subject: input.subject,
|
|
35
|
+
replyTo: input.replyTo,
|
|
36
|
+
react: input.react,
|
|
37
|
+
html: input.html,
|
|
38
|
+
text: input.text,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name = "email-resend"
|
|
2
|
+
version = "1.0.0"
|
|
3
|
+
category = "email"
|
|
4
|
+
description = "Resend client wrapper, React Email welcome template, and dev test route."
|
|
5
|
+
provides = ["email"]
|
|
6
|
+
requires = []
|
|
7
|
+
conflicts = []
|
|
8
|
+
requires_runtime = ["server"]
|
|
9
|
+
compatible_scaffolds = ["nextjs"]
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
runtime = ["resend", "react-email", "@react-email/components"]
|
|
13
|
+
|
|
14
|
+
[env]
|
|
15
|
+
required = ["RESEND_API_KEY"]
|
|
16
|
+
optional = ["RESEND_FROM"]
|
|
17
|
+
|
|
18
|
+
[[files]]
|
|
19
|
+
mode = "create"
|
|
20
|
+
from = "lib/email.ts"
|
|
21
|
+
to = "lib/email.ts"
|
|
22
|
+
|
|
23
|
+
[[files]]
|
|
24
|
+
mode = "create"
|
|
25
|
+
from = "emails/welcome.tsx"
|
|
26
|
+
to = "emails/welcome.tsx"
|
|
27
|
+
|
|
28
|
+
[[files]]
|
|
29
|
+
mode = "create"
|
|
30
|
+
from = "app/api/email/test/route.ts"
|
|
31
|
+
to = "app/api/email/test/route.ts"
|
|
32
|
+
|
|
33
|
+
[tasks]
|
|
34
|
+
file = "tasks.yaml"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Documentation-only example pack manifest.
|
|
2
|
+
# Real installable pack names must match /^[a-z][a-z0-9-]*$/.
|
|
3
|
+
name = "example"
|
|
4
|
+
|
|
5
|
+
# Semver version for this pack.
|
|
6
|
+
version = "1.0.0"
|
|
7
|
+
|
|
8
|
+
# Catalog grouping. This example demonstrates a database capability.
|
|
9
|
+
category = "db"
|
|
10
|
+
|
|
11
|
+
# One-line human summary shown by future catalog commands.
|
|
12
|
+
description = "Example database pack covering every manifest field."
|
|
13
|
+
|
|
14
|
+
# Capabilities this pack adds to the project.
|
|
15
|
+
provides = ["db"]
|
|
16
|
+
|
|
17
|
+
# Capabilities that must already exist or be installed in the same plan.
|
|
18
|
+
requires = []
|
|
19
|
+
|
|
20
|
+
# Capabilities that cannot coexist with this pack.
|
|
21
|
+
conflicts = ["db"]
|
|
22
|
+
|
|
23
|
+
# Template capabilities required from the active scaffold.
|
|
24
|
+
requires_runtime = ["server"]
|
|
25
|
+
|
|
26
|
+
# Named scaffold restriction. Empty or missing means no named restriction.
|
|
27
|
+
compatible_scaffolds = ["nextjs"]
|
|
28
|
+
|
|
29
|
+
# Runtime and development npm dependencies to install with Bun.
|
|
30
|
+
[dependencies]
|
|
31
|
+
runtime = ["example-db@^1.0.0"]
|
|
32
|
+
dev = ["example-db-cli@^1.0.0"]
|
|
33
|
+
|
|
34
|
+
# Environment variables used by this pack.
|
|
35
|
+
[env]
|
|
36
|
+
required = ["EXAMPLE_DATABASE_URL"]
|
|
37
|
+
optional = ["EXAMPLE_DATABASE_POOL_SIZE"]
|
|
38
|
+
|
|
39
|
+
# Create mode writes a new file and refuses to overwrite an existing target.
|
|
40
|
+
[[files]]
|
|
41
|
+
mode = "create"
|
|
42
|
+
from = "lib/example-db.ts"
|
|
43
|
+
to = "lib/example-db.ts"
|
|
44
|
+
|
|
45
|
+
# Append mode inserts a marked block once and stays idempotent on re-run.
|
|
46
|
+
[[files]]
|
|
47
|
+
mode = "append"
|
|
48
|
+
from = "env.example"
|
|
49
|
+
to = ".env.example"
|
|
50
|
+
|
|
51
|
+
# merge-json mode deep-merges JSON with deterministic key ordering.
|
|
52
|
+
[[files]]
|
|
53
|
+
mode = "merge-json"
|
|
54
|
+
from = "package.patch.json"
|
|
55
|
+
to = "package.json"
|
|
56
|
+
|
|
57
|
+
# Template mode renders Handlebars-style variables from spark.config.json.
|
|
58
|
+
[[files]]
|
|
59
|
+
mode = "template"
|
|
60
|
+
from = "lib/example-client.ts.hbs"
|
|
61
|
+
to = "lib/example-client.ts"
|
|
62
|
+
|
|
63
|
+
# Skills copied into .claude/skills and mirrored into .codex/skills.
|
|
64
|
+
[skills]
|
|
65
|
+
copy = ["skills/example-db-patterns"]
|
|
66
|
+
|
|
67
|
+
# Board tasks seeded into .ai/board.md during installation.
|
|
68
|
+
[tasks]
|
|
69
|
+
file = "tasks.yaml"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { stripe } from "@/lib/stripe";
|
|
2
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
3
|
+
|
|
4
|
+
export const runtime = "nodejs";
|
|
5
|
+
|
|
6
|
+
type PortalRequest = {
|
|
7
|
+
customerId?: string;
|
|
8
|
+
returnUrl?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export async function POST(request: NextRequest) {
|
|
12
|
+
const body = (await request.json().catch(() => ({}))) as PortalRequest;
|
|
13
|
+
|
|
14
|
+
if (!body.customerId) {
|
|
15
|
+
return NextResponse.json({ error: "customerId is required" }, { status: 400 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
19
|
+
customer: body.customerId,
|
|
20
|
+
return_url: body.returnUrl ?? `${request.nextUrl.origin}/billing`,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return NextResponse.redirect(session.url, 303);
|
|
24
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { stripe } from "@/lib/stripe";
|
|
2
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
3
|
+
|
|
4
|
+
export const runtime = "nodejs";
|
|
5
|
+
|
|
6
|
+
type CheckoutRequest = {
|
|
7
|
+
priceId?: string;
|
|
8
|
+
customerId?: string;
|
|
9
|
+
customerEmail?: string;
|
|
10
|
+
successUrl?: string;
|
|
11
|
+
cancelUrl?: string;
|
|
12
|
+
metadata?: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function isMetadata(value: unknown): value is Record<string, string> {
|
|
16
|
+
return (
|
|
17
|
+
typeof value === "object" &&
|
|
18
|
+
value !== null &&
|
|
19
|
+
!Array.isArray(value) &&
|
|
20
|
+
Object.values(value).every((entry) => typeof entry === "string")
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function POST(request: NextRequest) {
|
|
25
|
+
const body = (await request.json().catch(() => ({}))) as CheckoutRequest;
|
|
26
|
+
|
|
27
|
+
if (!body.priceId) {
|
|
28
|
+
return NextResponse.json({ error: "priceId is required" }, { status: 400 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!body.customerId && !body.customerEmail) {
|
|
32
|
+
return NextResponse.json(
|
|
33
|
+
{ error: "customerId or customerEmail is required" },
|
|
34
|
+
{ status: 400 },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const origin = request.nextUrl.origin;
|
|
39
|
+
const metadata = isMetadata(body.metadata) ? body.metadata : undefined;
|
|
40
|
+
|
|
41
|
+
const session = await stripe.checkout.sessions.create({
|
|
42
|
+
mode: "subscription",
|
|
43
|
+
customer: body.customerId,
|
|
44
|
+
customer_email: body.customerId ? undefined : body.customerEmail,
|
|
45
|
+
line_items: [
|
|
46
|
+
{
|
|
47
|
+
price: body.priceId,
|
|
48
|
+
quantity: 1,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
allow_promotion_codes: true,
|
|
52
|
+
success_url: body.successUrl ?? `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
53
|
+
cancel_url: body.cancelUrl ?? `${origin}/billing`,
|
|
54
|
+
metadata,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return NextResponse.json({ id: session.id, url: session.url });
|
|
58
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { stripe } from "@/lib/stripe";
|
|
2
|
+
import { headers } from "next/headers";
|
|
3
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
4
|
+
import type Stripe from "stripe";
|
|
5
|
+
|
|
6
|
+
export const runtime = "nodejs";
|
|
7
|
+
|
|
8
|
+
async function hasProcessedStripeEvent(_eventId: string): Promise<boolean> {
|
|
9
|
+
// Replace with a durable lookup in the app database before going live.
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function markStripeEventProcessed(_eventId: string): Promise<void> {
|
|
14
|
+
// Replace with an atomic insert keyed by Stripe event id before going live.
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
|
|
18
|
+
console.log("Stripe checkout completed", {
|
|
19
|
+
customer: session.customer,
|
|
20
|
+
subscription: session.subscription,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
|
|
25
|
+
console.log("Stripe subscription updated", {
|
|
26
|
+
customer: subscription.customer,
|
|
27
|
+
status: subscription.status,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
|
|
32
|
+
console.log("Stripe subscription deleted", {
|
|
33
|
+
customer: subscription.customer,
|
|
34
|
+
status: subscription.status,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function dispatchStripeEvent(event: Stripe.Event): Promise<void> {
|
|
39
|
+
switch (event.type) {
|
|
40
|
+
case "checkout.session.completed":
|
|
41
|
+
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
|
|
42
|
+
break;
|
|
43
|
+
case "customer.subscription.created":
|
|
44
|
+
case "customer.subscription.updated":
|
|
45
|
+
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
|
46
|
+
break;
|
|
47
|
+
case "customer.subscription.deleted":
|
|
48
|
+
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
|
49
|
+
break;
|
|
50
|
+
default:
|
|
51
|
+
console.log(`Unhandled Stripe event: ${event.type}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function POST(request: NextRequest) {
|
|
56
|
+
const body = await request.text();
|
|
57
|
+
const signature = (await headers()).get("stripe-signature");
|
|
58
|
+
|
|
59
|
+
if (!signature) {
|
|
60
|
+
return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let event: Stripe.Event;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
event = stripe.webhooks.constructEvent(
|
|
67
|
+
body,
|
|
68
|
+
signature,
|
|
69
|
+
process.env.STRIPE_WEBHOOK_SECRET ?? "",
|
|
70
|
+
);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
const message = error instanceof Error ? error.message : "Invalid Stripe signature";
|
|
73
|
+
return NextResponse.json({ error: message }, { status: 400 });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (await hasProcessedStripeEvent(event.id)) {
|
|
77
|
+
return NextResponse.json({ received: true, duplicate: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await dispatchStripeEvent(event);
|
|
81
|
+
await markStripeEventProcessed(event.id);
|
|
82
|
+
|
|
83
|
+
return NextResponse.json({ received: true });
|
|
84
|
+
}
|