@flaredev/cli 0.9.1 → 0.11.0
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/dist/index.js +360 -59
- package/package.json +2 -2
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +8 -8
- package/templates/app/components/dashboard/resource-form.tsx +14 -3
- package/templates/app/lib/auth.ts +222 -154
- package/templates/app/lib/resource/drizzle-rows.ts +7 -2
- package/templates/app/lib/resource/rows.ts +5 -1
- package/templates/app/lib/resource/store.ts +106 -15
- package/templates/locks/{package-lock.json → cloudflare/package-lock.json} +1211 -1414
- package/templates/locks/{pnpm-lock.yaml → cloudflare/pnpm-lock.yaml} +1088 -1311
- package/templates/locks/{yarn.lock → cloudflare/yarn.lock} +1099 -1229
- package/templates/locks/next/package-lock.json +13673 -0
- package/templates/locks/next/pnpm-lock.yaml +8232 -0
- package/templates/locks/next/yarn.lock +5747 -0
- package/templates/next/.env.example +26 -10
- package/templates/next/docker-compose.yml +74 -0
- package/templates/next/lib/auth.ts +68 -2
- package/templates/next/lib/db.ts +5 -1
- package/templates/next/lib/resource/prisma-rows.ts +3 -2
|
@@ -1,20 +1,36 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
1
|
+
# Copy to .env. `flare create` already wrote one with a secret and the local service
|
|
2
|
+
# addresses below, so this file is here for the values you change when you deploy.
|
|
3
|
+
#
|
|
4
|
+
# Two ways to run the services this app needs:
|
|
5
|
+
#
|
|
6
|
+
# With Docker docker compose up -d — Postgres, MinIO and Redis on your machine.
|
|
7
|
+
# The values below already match it.
|
|
8
|
+
# Without Docker Neon, Cloudflare R2 and Upstash. Replace the values below with theirs.
|
|
9
|
+
#
|
|
10
|
+
# Nothing in the app knows which you chose: MinIO speaks S3 like R2, and the Redis proxy
|
|
11
|
+
# speaks Upstash's REST protocol.
|
|
12
|
+
|
|
13
|
+
# Postgres. Docker: the compose file. Hosted: Neon (Dashboard -> Connection string),
|
|
14
|
+
# pooled rather than direct.
|
|
15
|
+
DATABASE_URL=postgresql://flare:flare@127.0.0.1:5432/app
|
|
3
16
|
|
|
4
17
|
# Sessions and signed upload URLs. Generate with: openssl rand -base64 32
|
|
5
18
|
BETTER_AUTH_SECRET=
|
|
19
|
+
# A bare origin: no trailing slash, or every sign-in returns 403.
|
|
6
20
|
BETTER_AUTH_URL=http://localhost:3000
|
|
7
21
|
|
|
8
|
-
# File uploads,
|
|
9
|
-
# R2 serves what it stores for free, which is why it
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
22
|
+
# File uploads. Docker: MinIO, below. Hosted: an R2 bucket (Cloudflare -> R2 -> Manage
|
|
23
|
+
# API tokens). R2 serves what it stores for free, which is why it is here rather than
|
|
24
|
+
# Blob storage.
|
|
25
|
+
R2_ENDPOINT=http://127.0.0.1:9000
|
|
26
|
+
R2_BUCKET=app-uploads
|
|
27
|
+
R2_ACCESS_KEY_ID=flare
|
|
28
|
+
R2_SECRET_ACCESS_KEY=flare-secret-123
|
|
14
29
|
|
|
15
30
|
# Optional. Rate limits and anything that has to be true across every instance.
|
|
16
|
-
|
|
17
|
-
|
|
31
|
+
# Docker: the serverless-redis-http proxy. Hosted: Upstash.
|
|
32
|
+
UPSTASH_REDIS_REST_URL=http://127.0.0.1:8079
|
|
33
|
+
UPSTASH_REDIS_REST_TOKEN=flare-local-token
|
|
18
34
|
|
|
19
35
|
# Optional. Without these, email is printed to the console instead of sent.
|
|
20
36
|
RESEND_API_KEY=
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# The services this app expects, running on your machine.
|
|
2
|
+
#
|
|
3
|
+
# docker compose up -d
|
|
4
|
+
#
|
|
5
|
+
# Without Docker you point the same variables at hosted services instead — Neon for
|
|
6
|
+
# Postgres, Cloudflare R2 for uploads, Upstash for Redis. Nothing in the app knows the
|
|
7
|
+
# difference: MinIO speaks S3 like R2 does, and the Redis proxy speaks Upstash's REST
|
|
8
|
+
# protocol, so `lib/storage.ts` and `lib/redis.ts` are the same code either way.
|
|
9
|
+
#
|
|
10
|
+
# `.env.example` already holds the values that match this file.
|
|
11
|
+
|
|
12
|
+
services:
|
|
13
|
+
postgres:
|
|
14
|
+
image: postgres:17
|
|
15
|
+
environment:
|
|
16
|
+
POSTGRES_USER: flare
|
|
17
|
+
POSTGRES_PASSWORD: flare
|
|
18
|
+
POSTGRES_DB: app
|
|
19
|
+
ports: ["5432:5432"]
|
|
20
|
+
volumes: [postgres-data:/var/lib/postgresql/data]
|
|
21
|
+
healthcheck:
|
|
22
|
+
test: ["CMD-SHELL", "pg_isready -U flare -d app"]
|
|
23
|
+
interval: 5s
|
|
24
|
+
timeout: 5s
|
|
25
|
+
retries: 10
|
|
26
|
+
|
|
27
|
+
# R2 speaks S3, and so does MinIO, which is how file uploads work locally without a
|
|
28
|
+
# Cloudflare account.
|
|
29
|
+
#
|
|
30
|
+
# bitnamilegacy, pinned: bitnami/minio stopped publishing tags, and both minio/minio on
|
|
31
|
+
# Docker Hub and quay.io now refuse an anonymous pull. This archived tag still works and
|
|
32
|
+
# creates the bucket on startup, so there is no setup step after `up`.
|
|
33
|
+
minio:
|
|
34
|
+
image: bitnamilegacy/minio:2025.7.23-debian-12-r5
|
|
35
|
+
environment:
|
|
36
|
+
MINIO_ROOT_USER: flare
|
|
37
|
+
MINIO_ROOT_PASSWORD: flare-secret-123
|
|
38
|
+
MINIO_DEFAULT_BUCKETS: app-uploads
|
|
39
|
+
# 9000 is the S3 API, 9001 the web console (http://localhost:9001).
|
|
40
|
+
ports: ["9000:9000", "9001:9001"]
|
|
41
|
+
volumes: [minio-data:/bitnami/minio/data]
|
|
42
|
+
healthcheck:
|
|
43
|
+
test: ["CMD", "curl", "-sf", "http://127.0.0.1:9000/minio/health/live"]
|
|
44
|
+
interval: 5s
|
|
45
|
+
timeout: 5s
|
|
46
|
+
retries: 20
|
|
47
|
+
|
|
48
|
+
redis:
|
|
49
|
+
image: redis:8-alpine
|
|
50
|
+
# Not published: only the proxy below talks to it, and the app never speaks RESP.
|
|
51
|
+
expose: ["6379"]
|
|
52
|
+
healthcheck:
|
|
53
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
54
|
+
interval: 5s
|
|
55
|
+
timeout: 5s
|
|
56
|
+
retries: 10
|
|
57
|
+
|
|
58
|
+
# `@upstash/redis` is a REST client, not a socket client, so it cannot talk to the Redis
|
|
59
|
+
# above directly. This proxy speaks Upstash's REST protocol in front of it, which keeps
|
|
60
|
+
# `lib/redis.ts` identical whether you are on your laptop or on Upstash.
|
|
61
|
+
redis-http:
|
|
62
|
+
image: hiett/serverless-redis-http:latest
|
|
63
|
+
environment:
|
|
64
|
+
SRH_MODE: env
|
|
65
|
+
SRH_TOKEN: flare-local-token
|
|
66
|
+
SRH_CONNECTION_STRING: redis://redis:6379
|
|
67
|
+
ports: ["8079:80"]
|
|
68
|
+
depends_on:
|
|
69
|
+
redis:
|
|
70
|
+
condition: service_healthy
|
|
71
|
+
|
|
72
|
+
volumes:
|
|
73
|
+
postgres-data:
|
|
74
|
+
minio-data:
|
|
@@ -16,8 +16,31 @@ import { sendEmailCode, sendMagicLink, sendPasswordReset, sendTwoFactorCode, sen
|
|
|
16
16
|
|
|
17
17
|
const vars = process.env as Record<string, string | undefined>;
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Optional: set when serving from a custom domain (e.g. https://example.com).
|
|
21
|
+
*
|
|
22
|
+
* Better Auth compares the request's Origin header against this string, and an Origin never
|
|
23
|
+
* carries a trailing slash. So `https://example.com/` — which is what you get copying a
|
|
24
|
+
* domain out of a browser bar or the Vercel dashboard — matches nothing, and every sign-in
|
|
25
|
+
* comes back 403 with nothing in the log saying why. Correct it and say so: a warning that
|
|
26
|
+
* leaves sign-in broken is not much better than the silence it replaces.
|
|
27
|
+
*/
|
|
28
|
+
function authBaseURL(raw: string | undefined): string | undefined {
|
|
29
|
+
const value = raw?.trim();
|
|
30
|
+
if (!value) return undefined;
|
|
31
|
+
// Only a trailing slash is removed. A path stays: an app served under one legitimately
|
|
32
|
+
// sets BETTER_AUTH_URL to https://example.com/app.
|
|
33
|
+
const url = value.replace(/\/+$/, "");
|
|
34
|
+
if (url !== value) {
|
|
35
|
+
console.warn(`[auth] BETTER_AUTH_URL has a trailing slash, which never matches an Origin header and makes every sign-in 403. Using "${url}" — set BETTER_AUTH_URL to that to silence this.`);
|
|
36
|
+
}
|
|
37
|
+
if (url && !/^https?:\/\//.test(url)) {
|
|
38
|
+
console.warn(`[auth] BETTER_AUTH_URL ("${url}") has no scheme, so it cannot match an Origin. Set it to a full origin, e.g. https://example.com.`);
|
|
39
|
+
}
|
|
40
|
+
return url || undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const configuredURL = authBaseURL(vars.BETTER_AUTH_URL);
|
|
21
44
|
|
|
22
45
|
/** A provider's credentials from the environment, when both are set. */
|
|
23
46
|
function credentials(prefix: string) {
|
|
@@ -84,6 +107,44 @@ async function guardPassword(path: string, body: Record<string, unknown> | undef
|
|
|
84
107
|
}
|
|
85
108
|
}
|
|
86
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The first account to exist becomes the admin.
|
|
112
|
+
*
|
|
113
|
+
* Generated resources are admin-only, so without this a fresh app has a database, an API
|
|
114
|
+
* and a dashboard that nobody can reach — and the usual fix for that is to open
|
|
115
|
+
* everything up, which is how an admin panel ends up on the internet. The person running
|
|
116
|
+
* sign-up on an empty database is the person setting the app up.
|
|
117
|
+
*
|
|
118
|
+
* Done here rather than in `databaseHooks.user.create`, because the admin plugin already
|
|
119
|
+
* registers one of those (`{ role: defaultRole, ...user }`) and Better Auth does not merge
|
|
120
|
+
* the two — a second one is silently never called. This runs after the row exists and
|
|
121
|
+
* states the rule in SQL, which no plugin can quietly win.
|
|
122
|
+
*
|
|
123
|
+
* Everyone after them keeps the plugin's default role, and no role reaches an admin-only
|
|
124
|
+
* resource until you grant one with `flare user:role them@example.com admin`.
|
|
125
|
+
*
|
|
126
|
+
* Two people signing up in the same instant would both see two rows and neither would be
|
|
127
|
+
* promoted, leaving an app with no admin. It needs a brand-new app and millisecond timing,
|
|
128
|
+
* and `flare user:role you@example.com admin` fixes it, so this stays the simple rule
|
|
129
|
+
* rather than a transaction nobody can read.
|
|
130
|
+
*/
|
|
131
|
+
let adminSettled = false;
|
|
132
|
+
|
|
133
|
+
async function promoteFirstUser() {
|
|
134
|
+
if (adminSettled) return;
|
|
135
|
+
// Two rows is all it takes to answer "is there exactly one?".
|
|
136
|
+
const rows = await prisma.user.findMany({ select: { id: true, role: true }, take: 2 });
|
|
137
|
+
if (rows.length !== 1) {
|
|
138
|
+
// Nobody yet: the next sign-up asks again. More than one: the question is settled.
|
|
139
|
+
adminSettled = rows.length > 1;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (rows[0]!.role !== "admin") {
|
|
143
|
+
await prisma.user.update({ where: { id: rows[0]!.id }, data: { role: "admin" } });
|
|
144
|
+
}
|
|
145
|
+
adminSettled = true;
|
|
146
|
+
}
|
|
147
|
+
|
|
87
148
|
export const auth = betterAuth({
|
|
88
149
|
appName: "__APP_NAME__",
|
|
89
150
|
secret: process.env.BETTER_AUTH_SECRET,
|
|
@@ -127,6 +188,11 @@ export const auth = betterAuth({
|
|
|
127
188
|
if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
|
|
128
189
|
await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
|
|
129
190
|
}),
|
|
191
|
+
// Any route can be the one that creates the first user — a password sign-up, a magic
|
|
192
|
+
// link, a social callback — so this asks after all of them rather than guessing which.
|
|
193
|
+
after: createAuthMiddleware(async () => {
|
|
194
|
+
await promoteFirstUser();
|
|
195
|
+
}),
|
|
130
196
|
},
|
|
131
197
|
plugins: [
|
|
132
198
|
// Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
|
package/templates/next/lib/db.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { PrismaPg } from "@prisma/adapter-pg";
|
|
2
|
-
|
|
2
|
+
// Relative, not "@/lib/generated/prisma/client". The alias is resolved by the bundler,
|
|
3
|
+
// and `flare seed` runs this file through tsx instead, which does not resolve it under
|
|
4
|
+
// `moduleResolution: "bundler"` — the seed died on its first import before reaching the
|
|
5
|
+
// database. The generated client lives inside lib/ anyway, so this is the shorter path.
|
|
6
|
+
import { PrismaClient } from "./generated/prisma/client";
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* The Prisma client, one per process.
|
|
@@ -129,8 +129,9 @@ export function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceR
|
|
|
129
129
|
return delegate.findUnique({ where: { id } });
|
|
130
130
|
},
|
|
131
131
|
|
|
132
|
-
async titles(ids, titleField) {
|
|
133
|
-
const
|
|
132
|
+
async titles(ids, titleField, owner) {
|
|
133
|
+
const where = owner ? { id: { in: ids }, [owner.field]: owner.value } : { id: { in: ids } };
|
|
134
|
+
const rows = await delegate.findMany({ where, select: { id: true, [titleField]: true } });
|
|
134
135
|
return rows.map((row) => ({ id: String(row.id), title: row[titleField] }));
|
|
135
136
|
},
|
|
136
137
|
|