create-softeneers-app 0.2.2 → 0.2.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/README.html +4 -4
- package/README.md +6 -6
- package/dist/args.js +23 -2
- package/dist/args.js.map +1 -1
- package/dist/fragments.js +1 -1
- package/dist/fragments.js.map +1 -1
- package/dist/index.js +10 -7
- package/dist/index.js.map +1 -1
- package/dist/prompts.js +12 -2
- package/dist/prompts.js.map +1 -1
- package/package.json +1 -1
- package/templates/express-api/.env.example +23 -0
- package/templates/express-api/README.md +68 -1
- package/templates/express-api/package.json +3 -0
- package/templates/express-api/softeneers.template.json +20 -1
- package/templates/express-api/src/email/mailer.ts +6 -0
- package/templates/express-api/src/email/routes.ts +26 -0
- package/templates/express-api/src/env.ts +18 -0
- package/templates/express-api/src/index.ts +23 -0
- package/templates/express-api/src/payments/routes.ts +44 -0
- package/templates/express-api/src/payments/stripe.ts +6 -0
- package/templates/express-api/src/payments/webhook.ts +43 -0
- package/templates/express-api/src/storage/routes.ts +28 -0
- package/templates/express-api/src/storage/store.ts +13 -0
- package/templates/hono-api/.env.example +23 -0
- package/templates/hono-api/README.md +69 -2
- package/templates/hono-api/package.json +3 -0
- package/templates/hono-api/softeneers.template.json +20 -1
- package/templates/hono-api/src/email/mailer.ts +6 -0
- package/templates/hono-api/src/email/routes.ts +24 -0
- package/templates/hono-api/src/env.ts +18 -0
- package/templates/hono-api/src/index.ts +20 -0
- package/templates/hono-api/src/payments/routes.ts +41 -0
- package/templates/hono-api/src/payments/stripe.ts +6 -0
- package/templates/hono-api/src/payments/webhook.ts +36 -0
- package/templates/hono-api/src/storage/routes.ts +29 -0
- package/templates/hono-api/src/storage/store.ts +13 -0
- package/templates/tanstack-start/.env.example +23 -0
- package/templates/tanstack-start/README.md +58 -3
- package/templates/tanstack-start/package.json +4 -0
- package/templates/tanstack-start/softeneers.template.json +28 -3
- package/templates/tanstack-start/src/lib/auth-client.ts +4 -0
- package/templates/tanstack-start/src/routes/account.tsx +47 -0
- package/templates/tanstack-start/src/routes/api/webhooks/stripe.ts +43 -0
- package/templates/tanstack-start/src/routes/billing.tsx +59 -0
- package/templates/tanstack-start/src/routes/index.tsx +15 -6
- package/templates/tanstack-start/src/routes/login.tsx +77 -0
- package/templates/tanstack-start/src/server/email.ts +27 -0
- package/templates/tanstack-start/src/server/env.ts +18 -0
- package/templates/tanstack-start/src/server/payments.ts +40 -0
- package/templates/tanstack-start/src/server/storage.ts +36 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createStorage } from "@softeneers/storage";
|
|
2
|
+
|
|
3
|
+
import { env } from "../env.js";
|
|
4
|
+
|
|
5
|
+
// An S3-compatible storage handle (AWS S3 / Cloudflare R2 / MinIO). No network
|
|
6
|
+
// call until you upload or sign a URL.
|
|
7
|
+
export const storage = createStorage({
|
|
8
|
+
accessKeyId: env.S3_ACCESS_KEY_ID,
|
|
9
|
+
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
|
10
|
+
bucket: env.S3_BUCKET,
|
|
11
|
+
region: env.S3_REGION,
|
|
12
|
+
endpoint: env.S3_ENDPOINT || undefined,
|
|
13
|
+
});
|
|
@@ -11,3 +11,26 @@ DB_PASSWORD=
|
|
|
11
11
|
AUTH_SECRET=dev-secret-change-me-to-a-long-random-string
|
|
12
12
|
AUTH_BASE_URL=http://localhost:4000
|
|
13
13
|
# #endif
|
|
14
|
+
# #if email
|
|
15
|
+
# Get a key at https://resend.com — the app boots without it; sending needs it.
|
|
16
|
+
RESEND_API_KEY=re_set_me
|
|
17
|
+
EMAIL_FROM=onboarding@resend.dev
|
|
18
|
+
# #endif
|
|
19
|
+
# #if storage
|
|
20
|
+
# S3-compatible storage (AWS S3 / Cloudflare R2 / MinIO).
|
|
21
|
+
S3_ACCESS_KEY_ID=
|
|
22
|
+
S3_SECRET_ACCESS_KEY=
|
|
23
|
+
S3_BUCKET=uploads
|
|
24
|
+
S3_REGION=auto
|
|
25
|
+
S3_ENDPOINT=
|
|
26
|
+
# #endif
|
|
27
|
+
# #if payments
|
|
28
|
+
# Stripe — the ONLY thing you must set for payments to work. Test keys from
|
|
29
|
+
# https://dashboard.stripe.com/test/apikeys ; webhook secret from `stripe listen`
|
|
30
|
+
# or the dashboard. Create two Prices and paste their ids below.
|
|
31
|
+
APP_URL=http://localhost:4000
|
|
32
|
+
STRIPE_SECRET_KEY=sk_test_set_me
|
|
33
|
+
STRIPE_WEBHOOK_SECRET=whsec_set_me
|
|
34
|
+
STRIPE_PRICE_ID=price_set_me
|
|
35
|
+
STRIPE_SUBSCRIPTION_PRICE_ID=price_set_me
|
|
36
|
+
# #endif
|
|
@@ -30,7 +30,21 @@ npm run db:reset # drop, recreate, reseed
|
|
|
30
30
|
| PUT | `/api/cars/:id` | Update a car |
|
|
31
31
|
| DELETE | `/api/cars/:id` | Delete a car |
|
|
32
32
|
# #if auth
|
|
33
|
-
|
|
|
33
|
+
| ALL | `/api/auth/*` | better-auth routes |
|
|
34
|
+
# #endif
|
|
35
|
+
# #if email
|
|
36
|
+
| POST | `/api/email/welcome` | Send a welcome email |
|
|
37
|
+
# #endif
|
|
38
|
+
# #if storage
|
|
39
|
+
| POST | `/api/files/:key` | Upload an object |
|
|
40
|
+
| GET | `/api/files/:key/url`| Signed download URL |
|
|
41
|
+
| DELETE | `/api/files/:key` | Delete an object |
|
|
42
|
+
# #endif
|
|
43
|
+
# #if payments
|
|
44
|
+
| POST | `/api/payments/checkout` | One-time Stripe checkout |
|
|
45
|
+
| POST | `/api/payments/subscribe` | Subscription checkout |
|
|
46
|
+
| POST | `/api/payments/portal` | Billing portal |
|
|
47
|
+
| POST | `/api/webhooks/stripe` | Stripe webhook (verified) |
|
|
34
48
|
# #endif
|
|
35
49
|
|
|
36
50
|
```bash
|
|
@@ -61,7 +75,60 @@ npm run db:migrate && npm run db:seed
|
|
|
61
75
|
## Authentication
|
|
62
76
|
|
|
63
77
|
Email + password auth via better-auth ([`@softeneers/auth`](https://www.npmjs.com/package/@softeneers/auth)),
|
|
64
|
-
mounted at `/api/auth
|
|
78
|
+
mounted at `/api/auth/*` (`sign-up/email`, `sign-in/email`, `get-session`, …).
|
|
79
|
+
Set a strong `AUTH_SECRET` in `.env` before deploying.
|
|
80
|
+
# #endif
|
|
81
|
+
# #if email
|
|
82
|
+
|
|
83
|
+
## Email
|
|
84
|
+
|
|
85
|
+
Transactional email via Resend ([`@softeneers/email`](https://www.npmjs.com/package/@softeneers/email)).
|
|
86
|
+
Set `RESEND_API_KEY` (and `EMAIL_FROM`) in `.env`, then:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
curl -X POST localhost:4000/api/email/welcome -H 'content-type: application/json' \
|
|
90
|
+
-d '{"to":"you@example.com","name":"Ada"}'
|
|
91
|
+
```
|
|
92
|
+
# #endif
|
|
93
|
+
# #if storage
|
|
94
|
+
|
|
95
|
+
## Storage
|
|
96
|
+
|
|
97
|
+
S3-compatible uploads ([`@softeneers/storage`](https://www.npmjs.com/package/@softeneers/storage))
|
|
98
|
+
— works with AWS S3, Cloudflare R2, and MinIO. Set the `S3_*` keys in `.env`, then:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
curl -X POST --data-binary @photo.png localhost:4000/api/files/photo.png
|
|
102
|
+
curl localhost:4000/api/files/photo.png/url # → a signed download URL
|
|
103
|
+
```
|
|
104
|
+
# #endif
|
|
105
|
+
# #if payments
|
|
106
|
+
|
|
107
|
+
## Payments (Stripe)
|
|
108
|
+
|
|
109
|
+
Stripe checkout, subscriptions, the billing portal, and a verified webhook
|
|
110
|
+
([`@softeneers/payments`](https://www.npmjs.com/package/@softeneers/payments)) —
|
|
111
|
+
**all pre-wired**. The only thing you do is paste your keys into `.env`:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
STRIPE_SECRET_KEY=sk_test_…
|
|
115
|
+
STRIPE_WEBHOOK_SECRET=whsec_…
|
|
116
|
+
STRIPE_PRICE_ID=price_… # one-time price
|
|
117
|
+
STRIPE_SUBSCRIPTION_PRICE_ID=price_… # recurring price
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Get test keys at <https://dashboard.stripe.com/test/apikeys> and create two
|
|
121
|
+
Prices. Locally, forward webhooks with the Stripe CLI (this also prints the
|
|
122
|
+
`whsec_…` secret):
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
stripe listen --forward-to localhost:4000/api/webhooks/stripe
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Then `POST /api/payments/checkout` (or `/subscribe`) returns a Stripe URL to
|
|
129
|
+
redirect the customer to. The webhook handles `checkout.session.completed` and
|
|
130
|
+
the `customer.subscription.*` events — add your fulfilment logic in
|
|
131
|
+
`src/payments/webhook.ts`.
|
|
65
132
|
# #endif
|
|
66
133
|
|
|
67
134
|
## Getting started
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
"@hono/node-server": "^1.13.0",
|
|
22
22
|
"@softeneers/auth": "^0.1.0",
|
|
23
23
|
"@softeneers/db": "^0.1.0",
|
|
24
|
+
"@softeneers/email": "^0.1.0",
|
|
24
25
|
"@softeneers/env": "^0.1.0",
|
|
26
|
+
"@softeneers/payments": "^0.1.0",
|
|
27
|
+
"@softeneers/storage": "^0.1.0",
|
|
25
28
|
"dotenv": "^16.4.5",
|
|
26
29
|
"hono": "^4.6.0",
|
|
27
30
|
"mysql2": "^3.11.0"
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"toggles": {
|
|
2
|
+
"toggles": {
|
|
3
|
+
"db": false,
|
|
4
|
+
"auth": false,
|
|
5
|
+
"docker": false,
|
|
6
|
+
"email": false,
|
|
7
|
+
"storage": false,
|
|
8
|
+
"payments": false
|
|
9
|
+
},
|
|
3
10
|
"fragments": {
|
|
4
11
|
"db": {
|
|
5
12
|
"removePaths": ["src/db.ts", "src/scripts", "docker-compose.yml"],
|
|
@@ -12,6 +19,18 @@
|
|
|
12
19
|
},
|
|
13
20
|
"docker": {
|
|
14
21
|
"removePaths": ["docker-compose.yml"]
|
|
22
|
+
},
|
|
23
|
+
"email": {
|
|
24
|
+
"removePaths": ["src/email"],
|
|
25
|
+
"removeDeps": ["@softeneers/email"]
|
|
26
|
+
},
|
|
27
|
+
"storage": {
|
|
28
|
+
"removePaths": ["src/storage"],
|
|
29
|
+
"removeDeps": ["@softeneers/storage"]
|
|
30
|
+
},
|
|
31
|
+
"payments": {
|
|
32
|
+
"removePaths": ["src/payments"],
|
|
33
|
+
"removeDeps": ["@softeneers/payments"]
|
|
15
34
|
}
|
|
16
35
|
}
|
|
17
36
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
|
|
3
|
+
import { sendEmail } from "@softeneers/email";
|
|
4
|
+
|
|
5
|
+
import { env } from "../env.js";
|
|
6
|
+
import { mailer } from "./mailer.js";
|
|
7
|
+
|
|
8
|
+
export const email = new Hono();
|
|
9
|
+
|
|
10
|
+
// Demo: send a welcome email. POST { "to": "a@b.com", "name": "Ada" }
|
|
11
|
+
email.post("/welcome", async (c) => {
|
|
12
|
+
const body = await c.req.json().catch(() => ({}) as Record<string, unknown>);
|
|
13
|
+
const to = typeof body.to === "string" ? body.to : "";
|
|
14
|
+
const name = typeof body.name === "string" ? body.name : "there";
|
|
15
|
+
if (!to) return c.json({ message: "to (email address) is required." }, 400);
|
|
16
|
+
await sendEmail(mailer, {
|
|
17
|
+
from: env.EMAIL_FROM,
|
|
18
|
+
to,
|
|
19
|
+
subject: "Welcome!",
|
|
20
|
+
html: `<h1>Welcome, ${name}!</h1><p>Thanks for joining.</p>`,
|
|
21
|
+
text: `Welcome, ${name}! Thanks for joining.`,
|
|
22
|
+
});
|
|
23
|
+
return c.json({ sent: true });
|
|
24
|
+
});
|
|
@@ -19,5 +19,23 @@ export const env = createEnv({
|
|
|
19
19
|
AUTH_SECRET: z.string().min(16).default("dev-secret-change-me-to-a-long-random-string"),
|
|
20
20
|
AUTH_BASE_URL: z.string().default("http://localhost:4000"),
|
|
21
21
|
// #endif
|
|
22
|
+
// #if email
|
|
23
|
+
RESEND_API_KEY: z.string().default("re_set_me_in_dotenv"),
|
|
24
|
+
EMAIL_FROM: z.string().default("onboarding@resend.dev"),
|
|
25
|
+
// #endif
|
|
26
|
+
// #if storage
|
|
27
|
+
S3_ACCESS_KEY_ID: z.string().default(""),
|
|
28
|
+
S3_SECRET_ACCESS_KEY: z.string().default(""),
|
|
29
|
+
S3_BUCKET: z.string().default("uploads"),
|
|
30
|
+
S3_REGION: z.string().default("auto"),
|
|
31
|
+
S3_ENDPOINT: z.string().default(""),
|
|
32
|
+
// #endif
|
|
33
|
+
// #if payments
|
|
34
|
+
APP_URL: z.string().default("http://localhost:4000"),
|
|
35
|
+
STRIPE_SECRET_KEY: z.string().default("sk_test_set_me_in_dotenv"),
|
|
36
|
+
STRIPE_WEBHOOK_SECRET: z.string().default("whsec_set_me_in_dotenv"),
|
|
37
|
+
STRIPE_PRICE_ID: z.string().default("price_set_me_in_dotenv"),
|
|
38
|
+
STRIPE_SUBSCRIPTION_PRICE_ID: z.string().default("price_set_me_in_dotenv"),
|
|
39
|
+
// #endif
|
|
22
40
|
},
|
|
23
41
|
});
|
|
@@ -7,6 +7,16 @@ import { env } from "./env.js";
|
|
|
7
7
|
// #if auth
|
|
8
8
|
import { auth } from "./auth/auth.js";
|
|
9
9
|
// #endif
|
|
10
|
+
// #if email
|
|
11
|
+
import { email } from "./email/routes.js";
|
|
12
|
+
// #endif
|
|
13
|
+
// #if storage
|
|
14
|
+
import { files } from "./storage/routes.js";
|
|
15
|
+
// #endif
|
|
16
|
+
// #if payments
|
|
17
|
+
import { payments } from "./payments/routes.js";
|
|
18
|
+
import { stripeWebhook } from "./payments/webhook.js";
|
|
19
|
+
// #endif
|
|
10
20
|
|
|
11
21
|
const app = new Hono();
|
|
12
22
|
|
|
@@ -20,6 +30,16 @@ app.route("/api/cars", cars);
|
|
|
20
30
|
// better-auth speaks the Fetch API, so hand it the raw Request and return its Response.
|
|
21
31
|
app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw));
|
|
22
32
|
// #endif
|
|
33
|
+
// #if email
|
|
34
|
+
app.route("/api/email", email);
|
|
35
|
+
// #endif
|
|
36
|
+
// #if storage
|
|
37
|
+
app.route("/api/files", files);
|
|
38
|
+
// #endif
|
|
39
|
+
// #if payments
|
|
40
|
+
app.post("/api/webhooks/stripe", stripeWebhook);
|
|
41
|
+
app.route("/api/payments", payments);
|
|
42
|
+
// #endif
|
|
23
43
|
|
|
24
44
|
serve({ fetch: app.fetch, port: env.PORT }, (info) => {
|
|
25
45
|
console.log(`API running on http://localhost:${info.port}`);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
|
|
3
|
+
import { createBillingPortalSession, createCheckoutSession } from "@softeneers/payments";
|
|
4
|
+
|
|
5
|
+
import { env } from "../env.js";
|
|
6
|
+
import { stripe } from "./stripe.js";
|
|
7
|
+
|
|
8
|
+
export const payments = new Hono();
|
|
9
|
+
|
|
10
|
+
// One-time purchase → returns a Stripe Checkout URL.
|
|
11
|
+
payments.post("/checkout", async (c) => {
|
|
12
|
+
const session = await createCheckoutSession(stripe, {
|
|
13
|
+
mode: "payment",
|
|
14
|
+
priceId: env.STRIPE_PRICE_ID,
|
|
15
|
+
successUrl: `${env.APP_URL}/?paid=1`,
|
|
16
|
+
cancelUrl: `${env.APP_URL}/?canceled=1`,
|
|
17
|
+
});
|
|
18
|
+
return c.json({ url: session.url });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Subscription checkout.
|
|
22
|
+
payments.post("/subscribe", async (c) => {
|
|
23
|
+
const session = await createCheckoutSession(stripe, {
|
|
24
|
+
mode: "subscription",
|
|
25
|
+
priceId: env.STRIPE_SUBSCRIPTION_PRICE_ID,
|
|
26
|
+
successUrl: `${env.APP_URL}/?subscribed=1`,
|
|
27
|
+
cancelUrl: `${env.APP_URL}/?canceled=1`,
|
|
28
|
+
});
|
|
29
|
+
return c.json({ url: session.url });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Billing portal — pass ?customer=cus_…
|
|
33
|
+
payments.post("/portal", async (c) => {
|
|
34
|
+
const customer = c.req.query("customer") ?? "";
|
|
35
|
+
if (!customer) return c.json({ message: "A Stripe customer id (?customer=cus_…) is required." }, 400);
|
|
36
|
+
const session = await createBillingPortalSession(stripe, {
|
|
37
|
+
customer,
|
|
38
|
+
returnUrl: `${env.APP_URL}/`,
|
|
39
|
+
});
|
|
40
|
+
return c.json({ url: session.url });
|
|
41
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
|
|
3
|
+
import { constructWebhookEvent } from "@softeneers/payments";
|
|
4
|
+
|
|
5
|
+
import { env } from "../env.js";
|
|
6
|
+
import { stripe } from "./stripe.js";
|
|
7
|
+
|
|
8
|
+
// Stripe webhook handler. Reads the raw body via c.req.text() (needed for
|
|
9
|
+
// signature verification) and handles both payment and subscription events.
|
|
10
|
+
export async function stripeWebhook(c: Context) {
|
|
11
|
+
const signature = c.req.header("stripe-signature");
|
|
12
|
+
if (!signature) return c.text("Missing stripe-signature header.", 400);
|
|
13
|
+
|
|
14
|
+
let event;
|
|
15
|
+
try {
|
|
16
|
+
event = constructWebhookEvent(stripe, await c.req.text(), signature, env.STRIPE_WEBHOOK_SECRET);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.error("Stripe webhook signature verification failed:", error);
|
|
19
|
+
return c.text("Invalid signature.", 400);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
switch (event.type) {
|
|
23
|
+
case "checkout.session.completed":
|
|
24
|
+
console.log("✓ checkout.session.completed", event.data.object.id);
|
|
25
|
+
break;
|
|
26
|
+
case "customer.subscription.created":
|
|
27
|
+
case "customer.subscription.updated":
|
|
28
|
+
case "customer.subscription.deleted":
|
|
29
|
+
console.log(`✓ ${event.type}`, event.data.object.id);
|
|
30
|
+
break;
|
|
31
|
+
default:
|
|
32
|
+
console.log("Unhandled Stripe event:", event.type);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return c.json({ received: true });
|
|
36
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
|
|
3
|
+
import { deleteFile, getSignedDownloadUrl, uploadFile } from "@softeneers/storage";
|
|
4
|
+
|
|
5
|
+
import { storage } from "./store.js";
|
|
6
|
+
|
|
7
|
+
export const files = new Hono();
|
|
8
|
+
|
|
9
|
+
// Upload raw bytes: POST the file body to /api/files/:key
|
|
10
|
+
files.post("/:key", async (c) => {
|
|
11
|
+
const key = c.req.param("key");
|
|
12
|
+
await uploadFile(storage, {
|
|
13
|
+
key,
|
|
14
|
+
body: Buffer.from(await c.req.arrayBuffer()),
|
|
15
|
+
contentType: c.req.header("content-type"),
|
|
16
|
+
});
|
|
17
|
+
return c.json({ key }, 201);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Get a time-limited download URL for an object.
|
|
21
|
+
files.get("/:key/url", async (c) => {
|
|
22
|
+
return c.json({ url: await getSignedDownloadUrl(storage, c.req.param("key")) });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Delete an object.
|
|
26
|
+
files.delete("/:key", async (c) => {
|
|
27
|
+
await deleteFile(storage, c.req.param("key"));
|
|
28
|
+
return c.body(null, 204);
|
|
29
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createStorage } from "@softeneers/storage";
|
|
2
|
+
|
|
3
|
+
import { env } from "../env.js";
|
|
4
|
+
|
|
5
|
+
// An S3-compatible storage handle (AWS S3 / Cloudflare R2 / MinIO). No network
|
|
6
|
+
// call until you upload or sign a URL.
|
|
7
|
+
export const storage = createStorage({
|
|
8
|
+
accessKeyId: env.S3_ACCESS_KEY_ID,
|
|
9
|
+
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
|
10
|
+
bucket: env.S3_BUCKET,
|
|
11
|
+
region: env.S3_REGION,
|
|
12
|
+
endpoint: env.S3_ENDPOINT || undefined,
|
|
13
|
+
});
|
|
@@ -9,3 +9,26 @@ DB_PASSWORD=
|
|
|
9
9
|
AUTH_SECRET=dev-secret-change-me-to-a-long-random-string
|
|
10
10
|
AUTH_BASE_URL=http://localhost:3000
|
|
11
11
|
# #endif
|
|
12
|
+
# #if email
|
|
13
|
+
# Get a key at https://resend.com — the app boots without it; sending needs it.
|
|
14
|
+
RESEND_API_KEY=re_set_me
|
|
15
|
+
EMAIL_FROM=onboarding@resend.dev
|
|
16
|
+
# #endif
|
|
17
|
+
# #if storage
|
|
18
|
+
# S3-compatible storage (AWS S3 / Cloudflare R2 / MinIO).
|
|
19
|
+
S3_ACCESS_KEY_ID=
|
|
20
|
+
S3_SECRET_ACCESS_KEY=
|
|
21
|
+
S3_BUCKET=uploads
|
|
22
|
+
S3_REGION=auto
|
|
23
|
+
S3_ENDPOINT=
|
|
24
|
+
# #endif
|
|
25
|
+
# #if payments
|
|
26
|
+
# Stripe — the ONLY thing you must set for payments to work. Test keys from
|
|
27
|
+
# https://dashboard.stripe.com/test/apikeys ; webhook secret from `stripe listen`
|
|
28
|
+
# or the dashboard. Create two Prices and paste their ids below.
|
|
29
|
+
APP_URL=http://localhost:3000
|
|
30
|
+
STRIPE_SECRET_KEY=sk_test_set_me
|
|
31
|
+
STRIPE_WEBHOOK_SECRET=whsec_set_me
|
|
32
|
+
STRIPE_PRICE_ID=price_set_me
|
|
33
|
+
STRIPE_SUBSCRIPTION_PRICE_ID=price_set_me
|
|
34
|
+
# #endif
|
|
@@ -27,9 +27,23 @@ src/routes/cars.tsx cars CRUD UI (loader + server functions)
|
|
|
27
27
|
src/server/cars.ts createServerFn RPCs (list / create / remove)
|
|
28
28
|
src/server/store.ts data layer (MySQL or in-memory)
|
|
29
29
|
# #if auth
|
|
30
|
+
src/routes/login.tsx sign-in / sign-up UI
|
|
31
|
+
src/routes/account.tsx session + sign-out UI
|
|
32
|
+
src/lib/auth-client.ts better-auth/react client
|
|
30
33
|
src/routes/api/auth/$.ts better-auth catch-all route (/api/auth/*)
|
|
31
34
|
src/server/auth.ts better-auth instance
|
|
32
35
|
# #endif
|
|
36
|
+
# #if payments
|
|
37
|
+
src/routes/billing.tsx Buy / Subscribe UI
|
|
38
|
+
src/server/payments.ts Stripe server functions
|
|
39
|
+
src/routes/api/webhooks/stripe.ts verified Stripe webhook
|
|
40
|
+
# #endif
|
|
41
|
+
# #if email
|
|
42
|
+
src/server/email.ts sendWelcomeEmail server function
|
|
43
|
+
# #endif
|
|
44
|
+
# #if storage
|
|
45
|
+
src/server/storage.ts upload / signed-URL / delete server functions
|
|
46
|
+
# #endif
|
|
33
47
|
```
|
|
34
48
|
|
|
35
49
|
The `/cars` page loads data with a route `loader` and mutates via server
|
|
@@ -54,11 +68,52 @@ npm run db:migrate && npm run db:seed
|
|
|
54
68
|
# #endif
|
|
55
69
|
# #if auth
|
|
56
70
|
|
|
57
|
-
## Authentication
|
|
71
|
+
## Authentication (with UI)
|
|
58
72
|
|
|
59
73
|
Email + password auth via better-auth ([`@softeneers/auth`](https://www.npmjs.com/package/@softeneers/auth)),
|
|
60
|
-
served at `/api/auth
|
|
61
|
-
`
|
|
74
|
+
served at `/api/auth/*`, **with the UI included**: visit `/login` to sign up or
|
|
75
|
+
in, and `/account` to see the session and sign out (driven by the
|
|
76
|
+
`better-auth/react` client in `src/lib/auth-client.ts`). Set a strong
|
|
77
|
+
`AUTH_SECRET` in `.env`.
|
|
78
|
+
# #endif
|
|
79
|
+
# #if email
|
|
80
|
+
|
|
81
|
+
## Email
|
|
82
|
+
|
|
83
|
+
Transactional email via Resend ([`@softeneers/email`](https://www.npmjs.com/package/@softeneers/email)).
|
|
84
|
+
Set `RESEND_API_KEY` + `EMAIL_FROM` in `.env`, then call the `sendWelcomeEmail`
|
|
85
|
+
server function from any component.
|
|
86
|
+
# #endif
|
|
87
|
+
# #if storage
|
|
88
|
+
|
|
89
|
+
## Storage
|
|
90
|
+
|
|
91
|
+
S3-compatible uploads ([`@softeneers/storage`](https://www.npmjs.com/package/@softeneers/storage),
|
|
92
|
+
AWS S3 / Cloudflare R2 / MinIO) via the `uploadText` / `getFileUrl` / `removeFile`
|
|
93
|
+
server functions. Set the `S3_*` keys in `.env`.
|
|
94
|
+
# #endif
|
|
95
|
+
# #if payments
|
|
96
|
+
|
|
97
|
+
## Payments (Stripe)
|
|
98
|
+
|
|
99
|
+
Stripe checkout, subscriptions, the billing portal, and a verified webhook
|
|
100
|
+
([`@softeneers/payments`](https://www.npmjs.com/package/@softeneers/payments)) —
|
|
101
|
+
**all pre-wired, with a `/billing` page**. The only thing you do is paste your
|
|
102
|
+
keys into `.env`:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
STRIPE_SECRET_KEY=sk_test_…
|
|
106
|
+
STRIPE_WEBHOOK_SECRET=whsec_…
|
|
107
|
+
STRIPE_PRICE_ID=price_… # one-time price
|
|
108
|
+
STRIPE_SUBSCRIPTION_PRICE_ID=price_… # recurring price
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Get test keys at <https://dashboard.stripe.com/test/apikeys>, create two Prices,
|
|
112
|
+
and forward webhooks locally with the Stripe CLI:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
stripe listen --forward-to localhost:3000/api/webhooks/stripe
|
|
116
|
+
```
|
|
62
117
|
# #endif
|
|
63
118
|
|
|
64
119
|
## Getting started
|
|
@@ -19,7 +19,11 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@softeneers/auth": "^0.1.0",
|
|
21
21
|
"@softeneers/db": "^0.1.0",
|
|
22
|
+
"@softeneers/email": "^0.1.0",
|
|
22
23
|
"@softeneers/env": "^0.1.0",
|
|
24
|
+
"@softeneers/payments": "^0.1.0",
|
|
25
|
+
"@softeneers/storage": "^0.1.0",
|
|
26
|
+
"better-auth": "^1.6.0",
|
|
23
27
|
"@tailwindcss/vite": "^4.1.18",
|
|
24
28
|
"@tanstack/react-devtools": "latest",
|
|
25
29
|
"@tanstack/react-router": "latest",
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"toggles": {
|
|
2
|
+
"toggles": {
|
|
3
|
+
"db": false,
|
|
4
|
+
"auth": false,
|
|
5
|
+
"docker": false,
|
|
6
|
+
"email": false,
|
|
7
|
+
"storage": false,
|
|
8
|
+
"payments": false
|
|
9
|
+
},
|
|
3
10
|
"fragments": {
|
|
4
11
|
"db": {
|
|
5
12
|
"removePaths": ["src/server/db.ts", "src/server/scripts", "docker-compose.yml"],
|
|
@@ -7,11 +14,29 @@
|
|
|
7
14
|
"removeScripts": ["db:migrate", "db:seed", "db:reset"]
|
|
8
15
|
},
|
|
9
16
|
"auth": {
|
|
10
|
-
"removePaths": [
|
|
11
|
-
|
|
17
|
+
"removePaths": [
|
|
18
|
+
"src/server/auth.ts",
|
|
19
|
+
"src/routes/api/auth",
|
|
20
|
+
"src/lib/auth-client.ts",
|
|
21
|
+
"src/routes/login.tsx",
|
|
22
|
+
"src/routes/account.tsx"
|
|
23
|
+
],
|
|
24
|
+
"removeDeps": ["@softeneers/auth", "better-auth"]
|
|
12
25
|
},
|
|
13
26
|
"docker": {
|
|
14
27
|
"removePaths": ["docker-compose.yml"]
|
|
28
|
+
},
|
|
29
|
+
"email": {
|
|
30
|
+
"removePaths": ["src/server/email.ts"],
|
|
31
|
+
"removeDeps": ["@softeneers/email"]
|
|
32
|
+
},
|
|
33
|
+
"storage": {
|
|
34
|
+
"removePaths": ["src/server/storage.ts"],
|
|
35
|
+
"removeDeps": ["@softeneers/storage"]
|
|
36
|
+
},
|
|
37
|
+
"payments": {
|
|
38
|
+
"removePaths": ["src/server/payments.ts", "src/routes/api/webhooks", "src/routes/billing.tsx"],
|
|
39
|
+
"removeDeps": ["@softeneers/payments"]
|
|
15
40
|
}
|
|
16
41
|
}
|
|
17
42
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Link, createFileRoute, useRouter } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
import { authClient } from '../lib/auth-client'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/account')({ component: Account })
|
|
6
|
+
|
|
7
|
+
function Account() {
|
|
8
|
+
const router = useRouter()
|
|
9
|
+
const { data: session, isPending } = authClient.useSession()
|
|
10
|
+
|
|
11
|
+
if (isPending) {
|
|
12
|
+
return <div className="mx-auto max-w-sm p-8 text-gray-500">Loading…</div>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!session) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="mx-auto max-w-sm p-8">
|
|
18
|
+
<p>You're not signed in.</p>
|
|
19
|
+
<Link to="/login" className="mt-3 inline-block text-blue-600 hover:underline">
|
|
20
|
+
Go to sign in →
|
|
21
|
+
</Link>
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function signOut() {
|
|
27
|
+
await authClient.signOut()
|
|
28
|
+
router.navigate({ to: '/login' })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="mx-auto max-w-sm p-8">
|
|
33
|
+
<h1 className="text-3xl font-bold">Account</h1>
|
|
34
|
+
<p className="mt-3">
|
|
35
|
+
Signed in as <strong>{session.user.email}</strong>
|
|
36
|
+
{session.user.name ? ` (${session.user.name})` : ''}.
|
|
37
|
+
</p>
|
|
38
|
+
<button
|
|
39
|
+
className="mt-6 rounded border border-gray-300 px-4 py-2 font-medium"
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={signOut}
|
|
42
|
+
>
|
|
43
|
+
Sign out
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
import { constructWebhookEvent, createStripe } from '@softeneers/payments'
|
|
4
|
+
|
|
5
|
+
import { env } from '../../../server/env'
|
|
6
|
+
|
|
7
|
+
const stripe = createStripe(env.STRIPE_SECRET_KEY)
|
|
8
|
+
|
|
9
|
+
// Catch-all Stripe webhook server route. Reads the raw body for signature
|
|
10
|
+
// verification and handles both payment and subscription events.
|
|
11
|
+
export const Route = createFileRoute('/api/webhooks/stripe')({
|
|
12
|
+
server: {
|
|
13
|
+
handlers: {
|
|
14
|
+
POST: async ({ request }) => {
|
|
15
|
+
const signature = request.headers.get('stripe-signature')
|
|
16
|
+
if (!signature) return new Response('Missing stripe-signature header.', { status: 400 })
|
|
17
|
+
|
|
18
|
+
let event
|
|
19
|
+
try {
|
|
20
|
+
event = constructWebhookEvent(stripe, await request.text(), signature, env.STRIPE_WEBHOOK_SECRET)
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error('Stripe webhook signature verification failed:', error)
|
|
23
|
+
return new Response('Invalid signature.', { status: 400 })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
switch (event.type) {
|
|
27
|
+
case 'checkout.session.completed':
|
|
28
|
+
console.log('✓ checkout.session.completed', event.data.object.id)
|
|
29
|
+
break
|
|
30
|
+
case 'customer.subscription.created':
|
|
31
|
+
case 'customer.subscription.updated':
|
|
32
|
+
case 'customer.subscription.deleted':
|
|
33
|
+
console.log(`✓ ${event.type}`, event.data.object.id)
|
|
34
|
+
break
|
|
35
|
+
default:
|
|
36
|
+
console.log('Unhandled Stripe event:', event.type)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return Response.json({ received: true })
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
})
|