@flaredev/cli 0.10.0 → 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:
@@ -107,6 +107,44 @@ async function guardPassword(path: string, body: Record<string, unknown> | undef
107
107
  }
108
108
  }
109
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
+
110
148
  export const auth = betterAuth({
111
149
  appName: "__APP_NAME__",
112
150
  secret: process.env.BETTER_AUTH_SECRET,
@@ -150,6 +188,11 @@ export const auth = betterAuth({
150
188
  if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
151
189
  await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
152
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
+ }),
153
196
  },
154
197
  plugins: [
155
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