@forgeailab/create-spark 0.1.1 → 0.1.3
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/.claude/skills/architecture-cutline/SKILL.md +96 -0
- package/.claude/skills/board-review/SKILL.md +77 -0
- package/.claude/skills/code-review/SKILL.md +76 -0
- package/.claude/skills/execute-task/SKILL.md +80 -0
- package/.claude/skills/idea-sharpen/SKILL.md +65 -0
- package/.claude/skills/implementation-brief/SKILL.md +87 -0
- package/.claude/skills/mvp-board/SKILL.md +95 -0
- package/.claude/skills/mvp-grill/SKILL.md +60 -0
- package/.claude/skills/mvp-spec/SKILL.md +78 -0
- package/.claude/skills/new-pack/SKILL.md +156 -0
- package/.claude/skills/next-task/SKILL.md +65 -0
- package/.claude/skills/pack-add/SKILL.md +64 -0
- package/.claude/skills/pack-resolve/SKILL.md +67 -0
- package/.claude/skills/parallel-execution/SKILL.md +68 -0
- package/.claude/skills/qa-verify/SKILL.md +77 -0
- package/.claude/skills/risk-check/SKILL.md +88 -0
- package/.claude/skills/sync-board/SKILL.md +76 -0
- package/.claude/skills/ux-theme/SKILL.md +93 -0
- package/.codex/skills/architecture-cutline/SKILL.md +94 -0
- package/.codex/skills/board-review/SKILL.md +75 -0
- package/.codex/skills/code-review/SKILL.md +73 -0
- package/.codex/skills/execute-task/SKILL.md +76 -0
- package/.codex/skills/idea-sharpen/SKILL.md +63 -0
- package/.codex/skills/implementation-brief/SKILL.md +85 -0
- package/.codex/skills/mvp-board/SKILL.md +93 -0
- package/.codex/skills/mvp-grill/SKILL.md +58 -0
- package/.codex/skills/mvp-spec/SKILL.md +76 -0
- package/.codex/skills/new-pack/SKILL.md +153 -0
- package/.codex/skills/next-task/SKILL.md +64 -0
- package/.codex/skills/pack-add/SKILL.md +62 -0
- package/.codex/skills/pack-resolve/SKILL.md +65 -0
- package/.codex/skills/parallel-execution/SKILL.md +66 -0
- package/.codex/skills/qa-verify/SKILL.md +74 -0
- package/.codex/skills/risk-check/SKILL.md +86 -0
- package/.codex/skills/sync-board/SKILL.md +72 -0
- package/.codex/skills/ux-theme/SKILL.md +91 -0
- package/package.json +10 -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/scripts/sync-skills.ts +223 -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/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/spark.config.json +4 -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,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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createStripeClient,
|
|
3
|
+
createCheckoutSession as createStripeCheckoutSession,
|
|
4
|
+
createBillingPortalSession as createStripeBillingPortalSession,
|
|
5
|
+
verifyWebhookSignature as verifyStripeWebhookSignature,
|
|
6
|
+
} from '@forgeailab/spark-stripe-helpers';
|
|
7
|
+
|
|
8
|
+
function requireEnv(name: string): string {
|
|
9
|
+
const value = process.env[name];
|
|
10
|
+
if (!value) {
|
|
11
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const stripe = createStripeClient(requireEnv('STRIPE_SECRET_KEY'), {
|
|
17
|
+
appInfo: { name: 'spark payments-stripe' },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export function getStripePublishableKey(): string {
|
|
21
|
+
return requireEnv('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type CheckoutInput = {
|
|
25
|
+
origin: string;
|
|
26
|
+
priceId: string;
|
|
27
|
+
customerEmail?: string;
|
|
28
|
+
customerId?: string;
|
|
29
|
+
successUrl?: string;
|
|
30
|
+
cancelUrl?: string;
|
|
31
|
+
metadata?: Record<string, string>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function createCheckoutSession(input: CheckoutInput) {
|
|
35
|
+
return createStripeCheckoutSession(stripe, {
|
|
36
|
+
priceId: input.priceId,
|
|
37
|
+
customerId: input.customerId,
|
|
38
|
+
customerEmail: input.customerEmail,
|
|
39
|
+
successUrl:
|
|
40
|
+
input.successUrl ?? `${input.origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
41
|
+
cancelUrl: input.cancelUrl ?? `${input.origin}/billing`,
|
|
42
|
+
metadata: input.metadata,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type PortalInput = { customerId: string; returnUrl: string };
|
|
47
|
+
|
|
48
|
+
export async function createBillingPortalSession(input: PortalInput) {
|
|
49
|
+
return createStripeBillingPortalSession(stripe, input);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type WebhookInput = { payload: string; signature: string; secret?: string };
|
|
53
|
+
|
|
54
|
+
export function verifyWebhookSignature(input: WebhookInput) {
|
|
55
|
+
return verifyStripeWebhookSignature(stripe, {
|
|
56
|
+
payload: input.payload,
|
|
57
|
+
signatureHeader: input.signature,
|
|
58
|
+
secret: input.secret ?? requireEnv('STRIPE_WEBHOOK_SECRET'),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name = "payments-stripe"
|
|
2
|
+
version = "1.0.0"
|
|
3
|
+
category = "payments"
|
|
4
|
+
description = "Stripe Checkout, customer portal, and webhook handlers for subscriptions."
|
|
5
|
+
provides = ["payments"]
|
|
6
|
+
requires = ["db", "auth"]
|
|
7
|
+
conflicts = ["payments"]
|
|
8
|
+
requires_runtime = ["server"]
|
|
9
|
+
compatible_scaffolds = ["nextjs"]
|
|
10
|
+
|
|
11
|
+
[runtime_package]
|
|
12
|
+
package = "@forgeailab/spark-stripe-helpers"
|
|
13
|
+
version = "^0.1"
|
|
14
|
+
|
|
15
|
+
[dependencies]
|
|
16
|
+
runtime = ["@stripe/stripe-js"]
|
|
17
|
+
|
|
18
|
+
[env]
|
|
19
|
+
required = [
|
|
20
|
+
"STRIPE_SECRET_KEY",
|
|
21
|
+
"STRIPE_WEBHOOK_SECRET",
|
|
22
|
+
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[[files]]
|
|
26
|
+
mode = "create"
|
|
27
|
+
from = "lib/stripe.ts"
|
|
28
|
+
to = "lib/stripe.ts"
|
|
29
|
+
|
|
30
|
+
[[files]]
|
|
31
|
+
mode = "create"
|
|
32
|
+
from = "app/api/checkout/route.ts"
|
|
33
|
+
to = "app/api/checkout/route.ts"
|
|
34
|
+
|
|
35
|
+
[[files]]
|
|
36
|
+
mode = "create"
|
|
37
|
+
from = "app/api/webhooks/stripe/route.ts"
|
|
38
|
+
to = "app/api/webhooks/stripe/route.ts"
|
|
39
|
+
|
|
40
|
+
[[files]]
|
|
41
|
+
mode = "create"
|
|
42
|
+
from = "app/api/billing-portal/route.ts"
|
|
43
|
+
to = "app/api/billing-portal/route.ts"
|
|
44
|
+
|
|
45
|
+
[skills]
|
|
46
|
+
copy = ["skills/stripe-patterns"]
|
|
47
|
+
|
|
48
|
+
[tasks]
|
|
49
|
+
file = "tasks.yaml"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stripe-patterns
|
|
3
|
+
description: Build Stripe subscription flows with Checkout, Billing Portal, and signed webhooks. Use when implementing or reviewing billing behavior after the payments-stripe pack is installed.
|
|
4
|
+
allowed-tools:
|
|
5
|
+
- Read
|
|
6
|
+
- Write
|
|
7
|
+
- Edit
|
|
8
|
+
- Bash
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Skill: stripe-patterns
|
|
12
|
+
|
|
13
|
+
## Goal
|
|
14
|
+
|
|
15
|
+
Ship a small, reliable subscription billing slice: Checkout creates the initial
|
|
16
|
+
subscription, Billing Portal handles plan and payment-method changes, and
|
|
17
|
+
webhooks update the app database as the billing source of truth changes.
|
|
18
|
+
|
|
19
|
+
## Recommended model
|
|
20
|
+
|
|
21
|
+
Opus 4.7 or GPT-5.5 for pricing and lifecycle decisions. Sonnet 4.6 or GPT-5
|
|
22
|
+
family executor for routine route and UI wiring.
|
|
23
|
+
|
|
24
|
+
## Inputs
|
|
25
|
+
|
|
26
|
+
Read these before changing billing code:
|
|
27
|
+
|
|
28
|
+
- `.ai/product-spec.md` for the monetization promise and non-goals
|
|
29
|
+
- `.ai/architecture.md` for the chosen auth and database providers
|
|
30
|
+
- `.ai/board.md` for the exact billing task and acceptance criteria
|
|
31
|
+
- `lib/stripe.ts` for shared Stripe client setup
|
|
32
|
+
- `app/api/webhooks/stripe/route.ts` for event handling
|
|
33
|
+
|
|
34
|
+
If the spec does not say what users buy or what paid access unlocks, stop and
|
|
35
|
+
ask. Do not invent pricing rules inside implementation.
|
|
36
|
+
|
|
37
|
+
## Subscription Model
|
|
38
|
+
|
|
39
|
+
- Treat Stripe as the source of truth for payment state.
|
|
40
|
+
- Treat the app database as a query cache for product access decisions.
|
|
41
|
+
- Store Stripe customer id on the app user or organization record.
|
|
42
|
+
- Store subscription id, price id, status, current period dates, and cancel flag.
|
|
43
|
+
- Grant access from database state, not from client-side checkout responses.
|
|
44
|
+
- Prefer Stripe Checkout for first purchase and Billing Portal for later changes.
|
|
45
|
+
- Keep product and price creation in the Stripe dashboard unless the board says
|
|
46
|
+
the product catalog must be managed in-app.
|
|
47
|
+
|
|
48
|
+
## Webhook Rules
|
|
49
|
+
|
|
50
|
+
- Verify signatures with `STRIPE_WEBHOOK_SECRET` and the raw request body.
|
|
51
|
+
- Process webhooks idempotently by storing Stripe event ids in the database.
|
|
52
|
+
- Use an atomic insert or unique index for event id de-duplication.
|
|
53
|
+
- Acknowledge duplicate events with success, not an error.
|
|
54
|
+
- Update access on `checkout.session.completed`,
|
|
55
|
+
`customer.subscription.updated`, and `customer.subscription.deleted`.
|
|
56
|
+
- Add invoice event handling only when the product spec needs grace periods,
|
|
57
|
+
failed payment messaging, or invoice history.
|
|
58
|
+
|
|
59
|
+
## Checkout Rules
|
|
60
|
+
|
|
61
|
+
- Never accept price amounts from the browser.
|
|
62
|
+
- Accept only Stripe price ids that the server has allowlisted or loaded from
|
|
63
|
+
trusted configuration.
|
|
64
|
+
- Create or reuse exactly one Stripe customer for each billable account.
|
|
65
|
+
- Send stable metadata, such as user id or organization id, on Checkout Sessions.
|
|
66
|
+
- Redirect only to same-origin success and cancel URLs unless explicitly needed.
|
|
67
|
+
|
|
68
|
+
## Customer Portal Rules
|
|
69
|
+
|
|
70
|
+
- Require an authenticated user before creating a portal session.
|
|
71
|
+
- Resolve the Stripe customer id from the database, not from request body trust.
|
|
72
|
+
- Use the portal for payment method changes, cancellations, and plan changes
|
|
73
|
+
unless custom billing UX is required by the board.
|
|
74
|
+
|
|
75
|
+
## Common Pitfalls
|
|
76
|
+
|
|
77
|
+
- Do not mark a user paid immediately after `checkout.sessions.create`.
|
|
78
|
+
- Do not parse JSON before webhook signature verification.
|
|
79
|
+
- Do not skip webhook idempotency because Stripe retries events.
|
|
80
|
+
- Do not trust `customerEmail` as identity; it is only a checkout convenience.
|
|
81
|
+
- Do not create a new customer on every checkout attempt.
|
|
82
|
+
- Do not build custom plan management before Billing Portal is exhausted.
|
|
83
|
+
|
|
84
|
+
## Verification
|
|
85
|
+
|
|
86
|
+
Use Stripe CLI forwarding for local webhook tests:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
stripe listen --forward-to localhost:3000/api/webhooks/stripe
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Then create a Checkout Session, complete the test payment, and confirm the app
|
|
93
|
+
database changes only after the signed webhook is processed.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
epic: Payments
|
|
2
|
+
|
|
3
|
+
tasks:
|
|
4
|
+
- id: PAY-001
|
|
5
|
+
title: Configure products + prices in Stripe dashboard
|
|
6
|
+
status: Clarifying
|
|
7
|
+
acceptance:
|
|
8
|
+
- Stripe dashboard has at least one active subscription product and recurring price.
|
|
9
|
+
- The selected price ID is available to the checkout flow.
|
|
10
|
+
|
|
11
|
+
- id: PAY-002
|
|
12
|
+
title: Test webhook signing locally with stripe listen
|
|
13
|
+
status: Clarifying
|
|
14
|
+
acceptance:
|
|
15
|
+
- Local webhook forwarding reaches app/api/webhooks/stripe.
|
|
16
|
+
- A signed test event is accepted and unsigned payloads are rejected.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
services:
|
|
2
|
+
zero-cache:
|
|
3
|
+
image: rocicorp/zero:latest
|
|
4
|
+
restart: unless-stopped
|
|
5
|
+
environment:
|
|
6
|
+
ZERO_UPSTREAM_DB: ${ZERO_UPSTREAM_DB}
|
|
7
|
+
ZERO_CVR_DB: ${ZERO_CVR_DB:-${ZERO_UPSTREAM_DB}_cvr}
|
|
8
|
+
ZERO_CHANGE_DB: ${ZERO_CHANGE_DB:-${ZERO_UPSTREAM_DB}_change}
|
|
9
|
+
ZERO_REPLICA_FILE: /zero/replica.db
|
|
10
|
+
ZERO_AUTH_SECRET: ${ZERO_AUTH_SECRET}
|
|
11
|
+
ZERO_ADMIN_PASSWORD: ${ZERO_ADMIN_PASSWORD:-change-me}
|
|
12
|
+
ZERO_PUSH_URL: ${ZERO_PUSH_URL:-http://host.docker.internal:3000/api/zero/mutate}
|
|
13
|
+
ZERO_LOG_LEVEL: ${ZERO_LOG_LEVEL:-info}
|
|
14
|
+
ZERO_NUM_SYNC_WORKERS: "1"
|
|
15
|
+
ports:
|
|
16
|
+
- "${ZERO_PORT:-4848}:4848"
|
|
17
|
+
volumes:
|
|
18
|
+
- zero_data:/zero
|
|
19
|
+
extra_hosts:
|
|
20
|
+
- "host.docker.internal:host-gateway"
|
|
21
|
+
depends_on:
|
|
22
|
+
postgres:
|
|
23
|
+
condition: service_healthy
|
|
24
|
+
|
|
25
|
+
volumes:
|
|
26
|
+
zero_data:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- compose/zero-cache.yml
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createZeroClient as createSparkZeroClient } from '@forgeailab/spark-sync-zero';
|
|
4
|
+
import type { ZeroOptions } from '@forgeailab/spark-sync-zero';
|
|
5
|
+
import { schema } from './schema';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_ZERO_URL = 'http://localhost:4848';
|
|
8
|
+
|
|
9
|
+
export function createZeroOptions(): ZeroOptions {
|
|
10
|
+
return {
|
|
11
|
+
cacheURL: process.env.NEXT_PUBLIC_ZERO_URL ?? DEFAULT_ZERO_URL,
|
|
12
|
+
schema,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createZeroClient(options: ZeroOptions = createZeroOptions()) {
|
|
17
|
+
return createSparkZeroClient(options);
|
|
18
|
+
}
|