@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.
- package/dist/index.js +4516 -3014
- package/package.json +2 -2
- package/templates/app/.dev.vars.example +20 -0
- package/templates/app/app/dashboard/import-actions.ts +2 -2
- package/templates/app/app/dashboard/invitations/actions.ts +62 -0
- package/templates/app/app/dashboard/invitations/page.tsx +121 -0
- package/templates/app/app/invite/[token]/route.ts +28 -0
- package/templates/app/app/sign-up/page.tsx +61 -42
- package/templates/app/components/dashboard/fields/file-field.tsx +132 -12
- package/templates/app/components/dashboard/invite-form.tsx +86 -0
- package/templates/app/components/dashboard/record-detail.tsx +13 -2
- package/templates/app/components/dashboard/resource-form-page.tsx +9 -1
- package/templates/app/components/dashboard/resource-form.tsx +17 -4
- package/templates/app/components/dashboard/resource-table.tsx +7 -1
- package/templates/app/lib/api.ts +41 -3
- package/templates/app/lib/auth-config.ts +60 -38
- package/templates/app/lib/auth.ts +112 -2
- package/templates/app/lib/dashboard-nav.ts +5 -0
- package/templates/app/lib/invitations.ts +142 -0
- package/templates/app/lib/rate-limit.ts +71 -0
- package/templates/app/lib/resource/drizzle-rows.ts +7 -2
- package/templates/app/lib/resource/handlers.ts +9 -2
- package/templates/app/lib/resource/http.ts +24 -13
- package/templates/app/lib/resource/rows.ts +5 -1
- package/templates/app/lib/resource/store.ts +126 -38
- package/templates/app/lib/storage/cloudinary.ts +146 -0
- package/templates/app/lib/storage/uploadthing.ts +122 -0
- package/templates/app/lib/storage.ts +72 -6
- package/templates/app/lib/user-titles.ts +52 -0
- package/templates/app/worker/index.ts +4 -1
- package/templates/app/wrangler.jsonc +11 -0
- package/templates/locks/{package-lock.json → cloudflare/package-lock.json} +1674 -1997
- package/templates/locks/{pnpm-lock.yaml → cloudflare/pnpm-lock.yaml} +1591 -1844
- package/templates/locks/{yarn.lock → cloudflare/yarn.lock} +1312 -1460
- package/templates/locks/next/package-lock.json +13713 -0
- package/templates/locks/next/pnpm-lock.yaml +8252 -0
- package/templates/locks/next/yarn.lock +5790 -0
- package/templates/next/.env.example +26 -10
- package/templates/next/README.md +35 -3
- package/templates/next/docker-compose.yml +74 -0
- package/templates/next/lib/auth.ts +108 -2
- package/templates/next/lib/dashboard.ts +3 -1
- package/templates/next/lib/db.ts +5 -1
- package/templates/next/lib/invitations.ts +119 -0
- package/templates/next/lib/rate-limit.ts +83 -0
- package/templates/next/lib/resource/prisma-rows.ts +55 -11
- package/templates/next/lib/storage/disk.ts +128 -0
- package/templates/next/lib/storage/r2.ts +86 -0
- package/templates/next/lib/storage.ts +83 -66
- package/templates/next/lib/user-titles.ts +49 -0
|
@@ -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=
|
package/templates/next/README.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|