@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.
@@ -1,20 +1,36 @@
1
- # Postgres, from your Neon project (Dashboard → Connection string).
2
- DATABASE_URL=
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, on an R2 bucket (Cloudflare → R2 → Manage API tokens).
9
- # R2 serves what it stores for free, which is why it's here rather than Blob storage.
10
- R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
11
- R2_BUCKET=
12
- R2_ACCESS_KEY_ID=
13
- R2_SECRET_ACCESS_KEY=
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
- UPSTASH_REDIS_REST_URL=
17
- UPSTASH_REDIS_REST_TOKEN=
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
- /** Optional: set when serving from a custom domain (e.g. https://example.com). */
20
- const configuredURL = vars.BETTER_AUTH_URL;
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).
@@ -1,5 +1,9 @@
1
1
  import { PrismaPg } from "@prisma/adapter-pg";
2
- import { PrismaClient } from "@/lib/generated/prisma/client";
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 rows = await delegate.findMany({ where: { id: { in: ids } }, select: { id: true, [titleField]: true } });
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