@flaredev/cli 0.10.0 → 0.12.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.
Files changed (50) hide show
  1. package/dist/index.js +4516 -3014
  2. package/package.json +2 -2
  3. package/templates/app/.dev.vars.example +20 -0
  4. package/templates/app/app/dashboard/import-actions.ts +2 -2
  5. package/templates/app/app/dashboard/invitations/actions.ts +62 -0
  6. package/templates/app/app/dashboard/invitations/page.tsx +121 -0
  7. package/templates/app/app/invite/[token]/route.ts +28 -0
  8. package/templates/app/app/sign-up/page.tsx +61 -42
  9. package/templates/app/components/dashboard/fields/file-field.tsx +132 -12
  10. package/templates/app/components/dashboard/invite-form.tsx +86 -0
  11. package/templates/app/components/dashboard/record-detail.tsx +13 -2
  12. package/templates/app/components/dashboard/resource-form-page.tsx +9 -1
  13. package/templates/app/components/dashboard/resource-form.tsx +17 -4
  14. package/templates/app/components/dashboard/resource-table.tsx +7 -1
  15. package/templates/app/lib/api.ts +41 -3
  16. package/templates/app/lib/auth-config.ts +60 -38
  17. package/templates/app/lib/auth.ts +112 -2
  18. package/templates/app/lib/dashboard-nav.ts +5 -0
  19. package/templates/app/lib/invitations.ts +142 -0
  20. package/templates/app/lib/rate-limit.ts +71 -0
  21. package/templates/app/lib/resource/drizzle-rows.ts +7 -2
  22. package/templates/app/lib/resource/handlers.ts +9 -2
  23. package/templates/app/lib/resource/http.ts +24 -13
  24. package/templates/app/lib/resource/rows.ts +5 -1
  25. package/templates/app/lib/resource/store.ts +126 -38
  26. package/templates/app/lib/storage/cloudinary.ts +146 -0
  27. package/templates/app/lib/storage/uploadthing.ts +122 -0
  28. package/templates/app/lib/storage.ts +72 -6
  29. package/templates/app/lib/user-titles.ts +52 -0
  30. package/templates/app/worker/index.ts +4 -1
  31. package/templates/app/wrangler.jsonc +11 -0
  32. package/templates/locks/{package-lock.json → cloudflare/package-lock.json} +1674 -1997
  33. package/templates/locks/{pnpm-lock.yaml → cloudflare/pnpm-lock.yaml} +1591 -1844
  34. package/templates/locks/{yarn.lock → cloudflare/yarn.lock} +1312 -1460
  35. package/templates/locks/next/package-lock.json +13713 -0
  36. package/templates/locks/next/pnpm-lock.yaml +8252 -0
  37. package/templates/locks/next/yarn.lock +5790 -0
  38. package/templates/next/.env.example +26 -10
  39. package/templates/next/README.md +35 -3
  40. package/templates/next/docker-compose.yml +74 -0
  41. package/templates/next/lib/auth.ts +108 -2
  42. package/templates/next/lib/dashboard.ts +3 -1
  43. package/templates/next/lib/db.ts +5 -1
  44. package/templates/next/lib/invitations.ts +119 -0
  45. package/templates/next/lib/rate-limit.ts +83 -0
  46. package/templates/next/lib/resource/prisma-rows.ts +55 -11
  47. package/templates/next/lib/storage/disk.ts +128 -0
  48. package/templates/next/lib/storage/r2.ts +86 -0
  49. package/templates/next/lib/storage.ts +83 -66
  50. package/templates/next/lib/user-titles.ts +49 -0
@@ -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=
@@ -2,15 +2,47 @@
2
2
 
3
3
  A [Flare](https://flare-docs.codetotech.com) app on Next.js, Postgres and Prisma.
4
4
 
5
+ ## Local development
6
+
5
7
  ```bash
6
8
  cp .env.example .env # DATABASE_URL and BETTER_AUTH_SECRET, at least
7
- __PM__ run db:migrate # prisma migrate dev: write and apply a migration
9
+ __PM__ run db:migrate # prisma migrate dev: write and apply a migration locally
10
+ __PM__ run db:seed # run seed files in seeds/
8
11
  __PM__ run dev # start the dev server on http://localhost:3000
9
- __PM__ run build # production build
10
- __PM__ run deploy # deploy to Vercel
11
12
  ```
12
13
 
13
14
  After `flare gen resource`, run `__PM__ run db:migrate` to turn the new models
14
15
  in `prisma/schema/resources.prisma` into a migration.
15
16
 
17
+ ## Deploying to Vercel
18
+
19
+ Vercel builds on its own machines, but **does not touch your database during deployment**. A newly provisioned Postgres database starts empty; attempting to sign up or sign in before applying migrations will fail with `500 Internal Server Error`.
20
+
21
+ Apply migrations and initial seeds once from your local machine against your production database:
22
+
23
+ ```bash
24
+ # 1. Apply existing migrations to production (runs prisma migrate deploy)
25
+ DATABASE_URL="<your POSTGRES_PRISMA_URL>" npx flare migrate
26
+
27
+ # 2. Seed initial data (if needed)
28
+ DATABASE_URL="<your POSTGRES_PRISMA_URL>" __PM__ run db:seed
29
+ ```
30
+
31
+ ### Production Database Notes:
32
+ - **`POSTGRES_PRISMA_URL` vs `DATABASE_URL`**: Vercel Postgres integration defines `POSTGRES_PRISMA_URL`. Flare and Prisma look for `DATABASE_URL`. Pass it inline as shown above rather than rewriting your local `.env`.
33
+ - **Do not run `prisma migrate dev` against production**: Cloud databases reject creating shadow databases. Use `npx flare migrate` (`prisma migrate deploy`) or `npx prisma db push`.
34
+ - **`BETTER_AUTH_URL`**: Set without a trailing slash (e.g. `https://myapp.vercel.app`) to avoid 403 origin mismatch errors.
35
+
36
+ Full guide: https://flare-docs.codetotech.com/guides/vercel-deployment/
37
+
38
+ ## Commands
39
+
40
+ | Command | What it does |
41
+ | --- | --- |
42
+ | `__PM__ run dev` | Start dev server on http://localhost:3000 |
43
+ | `__PM__ run build` | Generate Prisma client and create production Next.js build |
44
+ | `__PM__ run deploy` | Deploy to Vercel (`vercel deploy --prod`) |
45
+ | `__PM__ run db:migrate` | Create and apply migrations locally (`prisma migrate dev`) |
46
+ | `__PM__ run db:seed` | Run seed scripts in `seeds/` |
47
+
16
48
  Docs: https://flare-docs.codetotech.com
@@ -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:
@@ -1,4 +1,6 @@
1
1
  import { after } from "next/server";
2
+ import { cookies } from "next/headers";
3
+ import { INVITE_COOKIE, invitationAllows, spendInvitation } from "./invitations";
2
4
  import { passkey } from "@better-auth/passkey";
3
5
  import { prismaAdapter } from "@better-auth/prisma-adapter";
4
6
  import { betterAuth } from "better-auth";
@@ -107,6 +109,86 @@ async function guardPassword(path: string, body: Record<string, unknown> | undef
107
109
  }
108
110
  }
109
111
 
112
+ /**
113
+ * Whether this account may be created at all — `authConfig.signup`.
114
+ *
115
+ * Checked where the user row is written rather than on each sign-up route, because there
116
+ * are four ways to end up with a new account (a password sign-up, a magic link, an email
117
+ * code, a social callback) and gating them one by one is how one gets missed.
118
+ *
119
+ * **The first account is always allowed.** An app nobody can sign into cannot be set up,
120
+ * and in `invite` mode there would be nobody to issue the invitation. Everybody after
121
+ * them goes through this; the first account also becomes the admin (see below).
122
+ */
123
+ async function assertSignUpAllowed(email: string): Promise<void> {
124
+ if (authConfig.signup === "open") return;
125
+ if ((await prisma.user.findFirst({ select: { id: true } })) === null) return;
126
+
127
+ if (authConfig.signup === "closed") {
128
+ throw new APIError("FORBIDDEN", { message: "This app is not accepting new accounts." });
129
+ }
130
+
131
+ // `invite`: the token was put in a cookie when the invitation link was opened.
132
+ const token = (await cookies()).get(INVITE_COOKIE)?.value;
133
+ if (!(await invitationAllows(token, email))) {
134
+ throw new APIError("FORBIDDEN", { message: "You need an invitation to create an account here." });
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Spend the invitation this sign-up used, once the account exists.
140
+ *
141
+ * Separate from the check because the account does not exist yet when the check runs: an
142
+ * invitation has to survive a sign-up that fails validation, and only be used up by one
143
+ * that worked.
144
+ */
145
+ async function spendUsedInvitation(): Promise<void> {
146
+ if (authConfig.signup !== "invite") return;
147
+ const jar = await cookies();
148
+ const token = jar.get(INVITE_COOKIE)?.value;
149
+ if (!token) return;
150
+ await spendInvitation(token);
151
+ jar.delete(INVITE_COOKIE);
152
+ }
153
+
154
+ /**
155
+ * The first account to exist becomes the admin.
156
+ *
157
+ * Generated resources are admin-only, so without this a fresh app has a database, an API
158
+ * and a dashboard that nobody can reach — and the usual fix for that is to open
159
+ * everything up, which is how an admin panel ends up on the internet. The person running
160
+ * sign-up on an empty database is the person setting the app up.
161
+ *
162
+ * Done here rather than in `databaseHooks.user.create`, because the admin plugin already
163
+ * registers one of those (`{ role: defaultRole, ...user }`) and Better Auth does not merge
164
+ * the two — a second one is silently never called. This runs after the row exists and
165
+ * states the rule in SQL, which no plugin can quietly win.
166
+ *
167
+ * Everyone after them keeps the plugin's default role, and no role reaches an admin-only
168
+ * resource until you grant one with `flare user:role them@example.com admin`.
169
+ *
170
+ * Two people signing up in the same instant would both see two rows and neither would be
171
+ * promoted, leaving an app with no admin. It needs a brand-new app and millisecond timing,
172
+ * and `flare user:role you@example.com admin` fixes it, so this stays the simple rule
173
+ * rather than a transaction nobody can read.
174
+ */
175
+ let adminSettled = false;
176
+
177
+ async function promoteFirstUser() {
178
+ if (adminSettled) return;
179
+ // Two rows is all it takes to answer "is there exactly one?".
180
+ const rows = await prisma.user.findMany({ select: { id: true, role: true }, take: 2 });
181
+ if (rows.length !== 1) {
182
+ // Nobody yet: the next sign-up asks again. More than one: the question is settled.
183
+ adminSettled = rows.length > 1;
184
+ return;
185
+ }
186
+ if (rows[0]!.role !== "admin") {
187
+ await prisma.user.update({ where: { id: rows[0]!.id }, data: { role: "admin" } });
188
+ }
189
+ adminSettled = true;
190
+ }
191
+
110
192
  export const auth = betterAuth({
111
193
  appName: "__APP_NAME__",
112
194
  secret: process.env.BETTER_AUTH_SECRET,
@@ -145,16 +227,40 @@ export const auth = betterAuth({
145
227
  // verification email doesn't hold the sign-up request open.
146
228
  backgroundTasks: { handler: (promise: Promise<unknown>) => after(() => promise) },
147
229
  },
230
+ databaseHooks: {
231
+ user: {
232
+ create: {
233
+ // The one place every sign-up passes through, whichever route it came in on.
234
+ before: async (user: { email: string }) => {
235
+ await assertSignUpAllowed(user.email);
236
+ return { data: user };
237
+ },
238
+ },
239
+ },
240
+ },
148
241
  hooks: {
149
242
  before: createAuthMiddleware(async (ctx) => {
150
243
  if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
151
244
  await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
152
245
  }),
246
+ // Any route can be the one that creates the first user — a password sign-up, a magic
247
+ // link, a social callback — so this asks after all of them rather than guessing which.
248
+ after: createAuthMiddleware(async () => {
249
+ await promoteFirstUser();
250
+ await spendUsedInvitation();
251
+ }),
153
252
  },
154
253
  plugins: [
155
- // Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
254
+ // Adds user.role (default "user") and admin APIs. The dashboard allows ADMIN_ROLES (lib/dashboard.ts).
156
255
  admin({ defaultRole: "user", adminRoles: ["admin"] }),
157
- magicLink({ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }), expiresIn: 5 * 60, disableSignUp: false }),
256
+ // disableSignUp follows the mode: a magic link to an address with no account would
257
+ // otherwise create one, which is a sign-up through a side door. In `invite` mode the
258
+ // link is still how an invited person signs *in* — they have an account by then.
259
+ magicLink({
260
+ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }),
261
+ expiresIn: 5 * 60,
262
+ disableSignUp: authConfig.signup !== "open",
263
+ }),
158
264
  emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
159
265
  twoFactor({
160
266
  issuer: "__APP_NAME__",
@@ -80,7 +80,9 @@ export function dashboardStore(name: string): ResourceStore {
80
80
  if (!store) {
81
81
  store = createResourceStore({
82
82
  resource: entry.resource,
83
- rows: prismaRows(entry.delegate, prisma),
83
+ // The descriptor goes in so the store can translate enum values both ways:
84
+ // Prisma's client speaks member names, so the column's "A+" is "A_2b" to it.
85
+ rows: prismaRows(entry.delegate, prisma, entry.resource),
84
86
  // The admin is not exempt from ownership. A policy that confines staff to their own
85
87
  // rows has to mean the same thing here as it does over the API, or the dashboard is
86
88
  // a way around it.
@@ -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.
@@ -0,0 +1,119 @@
1
+ // This file is yours.
2
+ //
3
+ // Flare copies the code that turns a resource descriptor into a working endpoint into
4
+ // your app rather than hiding it in node_modules, for the same reason shadcn/ui copies
5
+ // a component: you cannot fix, read or reason about what you cannot see.
6
+ //
7
+ // `flare diff` shows how your copy differs from the version Flare ships, and
8
+ // `flare update` applies the parts you choose. Neither runs unless you ask.
9
+
10
+ /**
11
+ * Invitations, for an app whose sign-up is `invite` (see lib/auth-config.ts) — the
12
+ * Prisma half.
13
+ *
14
+ * **They live in the `Verification` model**, which Better Auth already has and already
15
+ * uses for exactly this shape of thing: a single-use token with an expiry. Reusing it
16
+ * means invitations need no model of their own and so no migration — an app switching to
17
+ * `invite` changes one line in auth-config.ts and nothing else.
18
+ *
19
+ * Rows are keyed `invite:<hash>`. Only the hash is stored, never the token itself, for the
20
+ * same reason a password is not: a leaked database should not be a pile of working
21
+ * invitations.
22
+ */
23
+ import { prisma } from "@/lib/db";
24
+
25
+ /** How long an invitation is good for, unless you say otherwise. */
26
+ export const DEFAULT_INVITE_DAYS = 7;
27
+
28
+ /** The cookie an opened invitation link leaves behind, for the sign-up that follows. */
29
+ export const INVITE_COOKIE = "flare_invite";
30
+
31
+ /** How long that cookie lives: long enough to fill in a form, not long enough to forget. */
32
+ export const INVITE_COOKIE_MAX_AGE = 60 * 60;
33
+
34
+ const KEY_PREFIX = "invite:";
35
+
36
+ /** WebCrypto, so this file does not depend on which runtime it is loaded in. */
37
+ const randomToken = () => {
38
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
39
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
40
+ };
41
+
42
+ /** Only the hash is stored, so a copy of the table is not a pile of working invitations. */
43
+ async function hash(token: string): Promise<string> {
44
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
45
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
46
+ }
47
+
48
+ const key = async (token: string) => `${KEY_PREFIX}${await hash(token)}`;
49
+
50
+ /** What an invitation is for: one address, or anybody who has the link. */
51
+ export type InviteFor = { email: string } | { anyone: true };
52
+
53
+ /** An invitation. The token is returned once and never stored. */
54
+ export interface CreatedInvitation {
55
+ token: string;
56
+ expiresAt: Date;
57
+ /** Who it is for, or null when anybody with the link may use it. */
58
+ email: string | null;
59
+ }
60
+
61
+ /** Create one. The returned token is the only copy. */
62
+ export async function createInvitation(options: { for: InviteFor; days?: number }): Promise<CreatedInvitation> {
63
+ const token = randomToken();
64
+ const email = "email" in options.for ? options.for.email.trim().toLowerCase() : null;
65
+ const expiresAt = new Date(Date.now() + (options.days ?? DEFAULT_INVITE_DAYS) * 86_400_000);
66
+ await prisma.verification.create({
67
+ // `*` rather than an empty string, so "for anybody" is a value and not a mistake.
68
+ data: { identifier: await key(token), value: email ?? "*", expiresAt },
69
+ });
70
+ return { token, expiresAt, email };
71
+ }
72
+
73
+ /** The link to send somebody, given the app's own origin. */
74
+ export const invitationLink = (baseUrl: string, token: string) => `${baseUrl.replace(/\/$/, "")}/invite/${token}`;
75
+
76
+ /**
77
+ * Whether this token is a live invitation for this email address.
78
+ *
79
+ * Checked, not spent: the account does not exist yet when this is asked, and an
80
+ * invitation should survive a sign-up that fails validation.
81
+ */
82
+ export async function invitationAllows(token: string | undefined, email: string): Promise<boolean> {
83
+ if (!token) return false;
84
+ const row = await prisma.verification.findFirst({ where: { identifier: await key(token) } });
85
+ if (!row) return false;
86
+ if (row.expiresAt.getTime() <= Date.now()) return false;
87
+ // An invitation addressed to somebody is for them alone; `*` is for whoever has the link.
88
+ return row.value === "*" || row.value === email.trim().toLowerCase();
89
+ }
90
+
91
+ /** Spend it, so the link cannot be used again. Called once the account exists. */
92
+ export async function spendInvitation(token: string | undefined): Promise<void> {
93
+ if (!token) return;
94
+ await prisma.verification.deleteMany({ where: { identifier: await key(token) } });
95
+ }
96
+
97
+ /** Invitations an admin can see: live ones, newest first. Never the tokens. */
98
+ export async function listInvitations(): Promise<{ id: string; email: string | null; expiresAt: Date; createdAt: Date }[]> {
99
+ const rows = await prisma.verification.findMany({
100
+ where: { identifier: { startsWith: KEY_PREFIX }, expiresAt: { gt: new Date() } },
101
+ orderBy: { createdAt: "desc" },
102
+ });
103
+ return rows.map((row) => ({ id: row.id, email: row.value === "*" ? null : row.value, expiresAt: row.expiresAt, createdAt: row.createdAt }));
104
+ }
105
+
106
+ /** Revoke one by its row id, so a link sent to the wrong address stops working. */
107
+ export async function revokeInvitation(id: string): Promise<void> {
108
+ await prisma.verification.deleteMany({ where: { id } });
109
+ }
110
+
111
+ /**
112
+ * Drop invitations that have expired.
113
+ *
114
+ * Only rows under the `invite:` prefix — the rest of this table is Better Auth's, and
115
+ * deleting its tokens would sign people out and break magic links.
116
+ */
117
+ export async function pruneInvitations(): Promise<void> {
118
+ await prisma.verification.deleteMany({ where: { identifier: { startsWith: KEY_PREFIX }, expiresAt: { lt: new Date() } } });
119
+ }
@@ -0,0 +1,83 @@
1
+ // This file is yours.
2
+ //
3
+ // Flare copies the code that turns a resource descriptor into a working endpoint into
4
+ // your app rather than hiding it in node_modules, for the same reason shadcn/ui copies
5
+ // a component: you cannot fix, read or reason about what you cannot see.
6
+ //
7
+ // `flare diff` shows how your copy differs from the version Flare ships, and
8
+ // `flare update` applies the parts you choose. Neither runs unless you ask.
9
+
10
+ /**
11
+ * Where a resource's rate limits are counted — the Next.js half.
12
+ *
13
+ * A descriptor says `rateLimit: { create: "10/min" }`; the generated route asks here
14
+ * whether this call is within it. The counter is Redis: `INCR` then `EXPIRE` on the first
15
+ * call of a window, which is two round trips at most and exact across every instance —
16
+ * the reason lib/redis.ts exists.
17
+ *
18
+ * **If Redis is not configured, nothing is limited.** Say so loudly once rather than
19
+ * failing every request: a serverless function that refuses to serve is worse than one
20
+ * that logs that a limit is not being enforced, and `flare doctor` reports it as a
21
+ * problem. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.
22
+ */
23
+ import { fixedWindow, rateLimitKey, type Rate, type RateLimitDecision } from "@flaredev/core";
24
+ import { redis } from "@/lib/redis";
25
+
26
+ /** Allowed, with no count kept — what an unconfigured limiter answers. */
27
+ const UNLIMITED: RateLimitDecision = { allowed: true, remaining: Number.MAX_SAFE_INTEGER, retryAfter: 0 };
28
+
29
+ let warned = false;
30
+
31
+ /**
32
+ * Count one call and say whether it was within the limit.
33
+ *
34
+ * `caller` is the signed-in account's id where there is one and the client IP otherwise,
35
+ * so a limit is per person rather than per everyone behind one connection.
36
+ */
37
+ export async function checkRateLimit(
38
+ resource: string,
39
+ action: string,
40
+ caller: string,
41
+ rate: Rate,
42
+ ): Promise<RateLimitDecision> {
43
+ // Captured in a local, because the narrowing from this check does not reach inside the
44
+ // callback below — the closure is called later, so TypeScript is right to doubt it.
45
+ const client = redis;
46
+ if (!client) {
47
+ if (!warned) {
48
+ warned = true;
49
+ console.warn(
50
+ "[flare] A resource declares a rateLimit, but Upstash Redis is not configured, so nothing is being limited. " +
51
+ "Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN (flare doctor will show you).",
52
+ );
53
+ }
54
+ return UNLIMITED;
55
+ }
56
+
57
+ const key = `flare:rate:${rateLimitKey(resource, action, caller)}`;
58
+ try {
59
+ return await fixedWindow(rate, async (windowSeconds) => {
60
+ const count = await client.incr(key);
61
+ // Only the call that opened the window sets the expiry, so a busy key's window is
62
+ // not pushed forward on every hit — that would make the limit unbounded.
63
+ if (count === 1) {
64
+ await client.expire(key, windowSeconds);
65
+ return { count, ttl: windowSeconds };
66
+ }
67
+ const ttl = await client.ttl(key);
68
+ // A key with no expiry (a crash between INCR and EXPIRE) would otherwise count for
69
+ // ever; give it one and treat this window as full-length.
70
+ if (ttl < 0) {
71
+ await client.expire(key, windowSeconds);
72
+ return { count, ttl: windowSeconds };
73
+ }
74
+ return { count, ttl };
75
+ });
76
+ } catch (error) {
77
+ // A counter that is unreachable must not take the endpoint down with it. Letting the
78
+ // call through is the safer failure: the alternative is an outage in the limiter
79
+ // becoming an outage in the app.
80
+ console.error("[flare] rate limit counter failed, allowing the request:", error);
81
+ return UNLIMITED;
82
+ }
83
+ }
@@ -20,6 +20,7 @@
20
20
  * package can't depend on one. The delegate is described structurally instead, which is
21
21
  * also what makes it easy to test without a database.
22
22
  */
23
+ import { enumTranslations, storedFields, type EnumTranslation, type Resource } from "@flaredev/core";
23
24
  import type { ConstraintHit, ResourceRows, Row, RowsQuery } from "./rows";
24
25
 
25
26
  /**
@@ -79,10 +80,46 @@ function fromIndexName(name: string): string {
79
80
  return match ? match[1]! : name;
80
81
  }
81
82
 
82
- export function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceRows {
83
+ /**
84
+ * Prisma's client speaks enum *member* names, not the values stored in the column.
85
+ *
86
+ * `A+` cannot be an identifier, so the schema declares the member `A_2b` with
87
+ * `@map("A+")`. The map applies to Postgres: the column holds `A+`, and the generated
88
+ * client still hands back `A_2b` and insists on it when writing. Left alone, the admin
89
+ * shows `A_2b`, the validators reject it because the descriptor says the value is `A+`,
90
+ * and a create fails outright.
91
+ *
92
+ * So values are translated at this boundary, and nothing above it has to know. A
93
+ * resource whose options are all identifiers already — `draft`, `published`, which is
94
+ * most of them — gets no translation table and pays nothing.
95
+ */
96
+ function translate(row: Row, enums: Record<string, EnumTranslation>, direction: "toValue" | "toMember"): Row {
97
+ let copy: Row | undefined;
98
+ for (const key of Object.keys(enums)) {
99
+ const current = row[key];
100
+ if (typeof current !== "string") continue;
101
+ const mapped = enums[key]![direction][current];
102
+ // An unknown member is left as it is rather than blanked: a value written before an
103
+ // option was removed should still be visible in the admin, not silently become null.
104
+ if (mapped === undefined || mapped === current) continue;
105
+ copy ??= { ...row };
106
+ copy[key] = mapped;
107
+ }
108
+ return copy ?? row;
109
+ }
110
+
111
+ export function prismaRows(delegate: PrismaDelegate, client: unknown, resource?: Resource): ResourceRows {
112
+ // Built once per store rather than per row. Without a descriptor there is nothing to
113
+ // translate, which keeps the old two-argument call working.
114
+ const enums = resource ? enumTranslations(storedFields(resource)) : {};
115
+ const hasEnums = Object.keys(enums).length > 0;
116
+ const toValue = (row: Row): Row => (hasEnums ? translate(row, enums, "toValue") : row);
117
+ const toMember = (row: Row): Row => (hasEnums ? translate(row, enums, "toMember") : row);
83
118
  /** The search and filter half of a where clause — everything but the cursor. */
84
119
  function matching(query: Pick<RowsQuery, "search" | "filters" | "deleted">): Record<string, unknown> {
85
- const where: Record<string, unknown> = { ...query.filters };
120
+ // A filter on an enum field arrives as the value the user sees, and has to reach
121
+ // Prisma as the member name.
122
+ const where: Record<string, unknown> = hasEnums ? { ...toMember(query.filters as Row) } : { ...query.filters };
86
123
  // A soft-deleting resource hides its deleted rows. Set on the query rather than in
87
124
  // `filters` because "only" is `not: null`, which the filter shape cannot say.
88
125
  if (query.deleted === "exclude") where.deletedAt = null;
@@ -106,18 +143,22 @@ export function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceR
106
143
  // Postgres compares a timestamp against a Date, not the number the cursor
107
144
  // carried. Without this, paging past the first page of a date-sorted list fails
108
145
  // with "Expected DateTime, provided Int".
109
- const value = isDate && (typeof query.cursor.value === "number" || typeof query.cursor.value === "string") ? new Date(query.cursor.value) : query.cursor.value;
146
+ let value = isDate && (typeof query.cursor.value === "number" || typeof query.cursor.value === "string") ? new Date(query.cursor.value) : query.cursor.value;
147
+ // Paging over an enum column: the cursor carries the value the row was handed
148
+ // out with, and Prisma compares member names.
149
+ if (hasEnums && enums[field] && typeof value === "string") value = enums[field]!.toMember[value] ?? value;
110
150
  // (sort, id) as one comparison, so rows sharing a sort value are neither skipped
111
151
  // nor repeated. AND-ed with the filters rather than replacing them.
112
152
  const keyset = [{ [field]: { [op]: value } }, { AND: [{ [field]: value }, { id: { [op]: id } }] }];
113
153
  where.AND = [...((where.AND as unknown[]) ?? []), { OR: keyset }];
114
154
  }
115
- return delegate.findMany({
155
+ const rows = await delegate.findMany({
116
156
  where,
117
157
  orderBy: [{ [query.sort.field]: query.sort.direction }, { id: query.sort.direction }],
118
158
  take: query.limit,
119
159
  ...(query.offset ? { skip: query.offset } : {}),
120
160
  });
161
+ return hasEnums ? rows.map(toValue) : rows;
121
162
  },
122
163
 
123
164
  countUpTo(query, limit) {
@@ -125,22 +166,25 @@ export function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceR
125
166
  return delegate.count({ where: matching(query), take: limit });
126
167
  },
127
168
 
128
- byId(id) {
129
- return delegate.findUnique({ where: { id } });
169
+ async byId(id) {
170
+ const row = await delegate.findUnique({ where: { id } });
171
+ return row ? toValue(row) : row;
130
172
  },
131
173
 
132
- async titles(ids, titleField) {
133
- const rows = await delegate.findMany({ where: { id: { in: ids } }, select: { id: true, [titleField]: true } });
174
+ async titles(ids, titleField, owner) {
175
+ const where = owner ? { id: { in: ids }, [owner.field]: owner.value } : { id: { in: ids } };
176
+ const rows = await delegate.findMany({ where, select: { id: true, [titleField]: true } });
134
177
  return rows.map((row) => ({ id: String(row.id), title: row[titleField] }));
135
178
  },
136
179
 
137
- insert(values) {
138
- return delegate.create({ data: values });
180
+ async insert(values) {
181
+ return toValue(await delegate.create({ data: hasEnums ? toMember(values as Row) : values }));
139
182
  },
140
183
 
141
184
  async update(id, values) {
142
185
  try {
143
- return await delegate.update({ where: { id }, data: values });
186
+ const row = await delegate.update({ where: { id }, data: hasEnums ? toMember(values as Row) : values });
187
+ return toValue(row);
144
188
  } catch (error) {
145
189
  if (codeOf(error) === NOT_FOUND) return null;
146
190
  throw error;