@flaredev/cli 0.11.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 +4777 -3372
- 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 +3 -1
- 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 +67 -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/handlers.ts +9 -2
- package/templates/app/lib/resource/http.ts +24 -13
- package/templates/app/lib/resource/store.ts +79 -27
- 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/cloudflare/package-lock.json +570 -690
- package/templates/locks/cloudflare/pnpm-lock.yaml +1061 -1091
- package/templates/locks/cloudflare/yarn.lock +363 -381
- package/templates/locks/next/package-lock.json +283 -243
- package/templates/locks/next/pnpm-lock.yaml +890 -870
- package/templates/locks/next/yarn.lock +338 -295
- package/templates/next/README.md +35 -3
- package/templates/next/lib/auth.ts +65 -2
- package/templates/next/lib/dashboard.ts +3 -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 +52 -9
- 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
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
|
|
@@ -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,48 @@ 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
|
+
|
|
110
154
|
/**
|
|
111
155
|
* The first account to exist becomes the admin.
|
|
112
156
|
*
|
|
@@ -183,6 +227,17 @@ export const auth = betterAuth({
|
|
|
183
227
|
// verification email doesn't hold the sign-up request open.
|
|
184
228
|
backgroundTasks: { handler: (promise: Promise<unknown>) => after(() => promise) },
|
|
185
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
|
+
},
|
|
186
241
|
hooks: {
|
|
187
242
|
before: createAuthMiddleware(async (ctx) => {
|
|
188
243
|
if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
|
|
@@ -192,12 +247,20 @@ export const auth = betterAuth({
|
|
|
192
247
|
// link, a social callback — so this asks after all of them rather than guessing which.
|
|
193
248
|
after: createAuthMiddleware(async () => {
|
|
194
249
|
await promoteFirstUser();
|
|
250
|
+
await spendUsedInvitation();
|
|
195
251
|
}),
|
|
196
252
|
},
|
|
197
253
|
plugins: [
|
|
198
|
-
// 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).
|
|
199
255
|
admin({ defaultRole: "user", adminRoles: ["admin"] }),
|
|
200
|
-
|
|
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
|
+
}),
|
|
201
264
|
emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
|
|
202
265
|
twoFactor({
|
|
203
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.
|
|
@@ -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,8 +166,9 @@ 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
174
|
async titles(ids, titleField, owner) {
|
|
@@ -135,13 +177,14 @@ export function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceR
|
|
|
135
177
|
return rows.map((row) => ({ id: String(row.id), title: row[titleField] }));
|
|
136
178
|
},
|
|
137
179
|
|
|
138
|
-
insert(values) {
|
|
139
|
-
return delegate.create({ data: values });
|
|
180
|
+
async insert(values) {
|
|
181
|
+
return toValue(await delegate.create({ data: hasEnums ? toMember(values as Row) : values }));
|
|
140
182
|
},
|
|
141
183
|
|
|
142
184
|
async update(id, values) {
|
|
143
185
|
try {
|
|
144
|
-
|
|
186
|
+
const row = await delegate.update({ where: { id }, data: hasEnums ? toMember(values as Row) : values });
|
|
187
|
+
return toValue(row);
|
|
145
188
|
} catch (error) {
|
|
146
189
|
if (codeOf(error) === NOT_FOUND) return null;
|
|
147
190
|
throw error;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import type { StorageBucket, StorageObjectBody } from "@flaredev/core";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Files on the local disk, the way Laravel's `local` disk works.
|
|
9
|
+
*
|
|
10
|
+
* For development without Docker, and for a single-server deployment where a volume is
|
|
11
|
+
* simpler than a bucket. It is not for anything that scales horizontally: two instances
|
|
12
|
+
* do not share a filesystem, and the second one serves 404s for everything the first
|
|
13
|
+
* one wrote.
|
|
14
|
+
*
|
|
15
|
+
* Bytes are served through this app's `/api/storage` route like R2's are, so a file is
|
|
16
|
+
* still private and still behind whatever policy issued the URL. Nothing is published
|
|
17
|
+
* by putting it here.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface DiskOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Where the files go, relative to the project root unless absolute.
|
|
23
|
+
* Default `storage/app` — Laravel's name for the same thing.
|
|
24
|
+
*/
|
|
25
|
+
dir?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The content type is kept beside the object rather than guessed from its extension.
|
|
30
|
+
*
|
|
31
|
+
* A key is whatever `createObjectKey` was given, and people upload files called
|
|
32
|
+
* `report` and `IMG_0421`. Guessing would serve those as a download instead of showing
|
|
33
|
+
* them, and would be wrong rather than merely unhelpful for anything renamed. The
|
|
34
|
+
* sidecar lives in a parallel tree so it can never collide with a real key — somebody
|
|
35
|
+
* does, eventually, upload a file called `photo.png.meta`.
|
|
36
|
+
*/
|
|
37
|
+
interface Meta {
|
|
38
|
+
contentType?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function diskBucket(options: DiskOptions = {}): StorageBucket {
|
|
42
|
+
const root = resolve(options.dir ?? join(process.cwd(), "storage", "app"));
|
|
43
|
+
const objects = join(root, "objects");
|
|
44
|
+
const metas = join(root, "meta");
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A key as a path under `base`, confined to it.
|
|
48
|
+
*
|
|
49
|
+
* `createStorage` already rejects `..` and leading slashes before a key reaches a
|
|
50
|
+
* bucket. This is the second lock on the same door: a bucket is a public function and
|
|
51
|
+
* a path traversal here reads any file the process can.
|
|
52
|
+
*/
|
|
53
|
+
const pathFor = (base: string, key: string, suffix = "") => {
|
|
54
|
+
const full = resolve(join(base, key + suffix));
|
|
55
|
+
if (full !== base && !full.startsWith(base + sep)) throw new Error(`Refusing to touch "${key}": it escapes the storage directory.`);
|
|
56
|
+
return full;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const write = async (file: string, data: Buffer | string) => {
|
|
60
|
+
try {
|
|
61
|
+
await mkdir(dirname(file), { recursive: true });
|
|
62
|
+
await writeFile(file, data);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// The one failure worth explaining. A serverless host gives you a read-only
|
|
65
|
+
// filesystem, so this driver cannot work there at all — and `EROFS` on a path
|
|
66
|
+
// nobody chose is a puzzling way to find that out, usually in production.
|
|
67
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
68
|
+
if (code === "EROFS" || code === "EACCES" || code === "EPERM") {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`The disk driver cannot write to ${root} (${code}). A serverless host — Vercel, Lambda, Workers — ` +
|
|
71
|
+
"has no writable filesystem that survives a request, so set STORAGE_DRIVER to r2, cloudinary or " +
|
|
72
|
+
"uploadthing for anything deployed there. Disk is for local development and for a server you own.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
async get(key: string): Promise<StorageObjectBody | null> {
|
|
81
|
+
const file = pathFor(objects, key);
|
|
82
|
+
let size: number;
|
|
83
|
+
let mtimeMs: number;
|
|
84
|
+
try {
|
|
85
|
+
const info = await stat(file);
|
|
86
|
+
if (!info.isFile()) return null;
|
|
87
|
+
size = info.size;
|
|
88
|
+
mtimeMs = info.mtimeMs;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let meta: Meta = {};
|
|
94
|
+
try {
|
|
95
|
+
meta = JSON.parse(await readFile(pathFor(metas, key, ".json"), "utf8")) as Meta;
|
|
96
|
+
} catch {
|
|
97
|
+
// Written before the sidecar existed, or removed by hand. The object is still
|
|
98
|
+
// the thing being asked for, so serve it as bytes rather than refusing.
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
// Node's stream, as the web stream the route writes to the response.
|
|
103
|
+
body: Readable.toWeb(createReadStream(file)) as ReadableStream,
|
|
104
|
+
size,
|
|
105
|
+
// Strong enough for a cache: a write changes one or the other.
|
|
106
|
+
httpEtag: `"${size.toString(16)}-${Math.floor(mtimeMs).toString(16)}"`,
|
|
107
|
+
httpMetadata: { contentType: meta.contentType },
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async put(key, value, putOptions) {
|
|
112
|
+
// The route hands over a stream of known length, already capped by the field's
|
|
113
|
+
// maxBytes and checked against its declared type.
|
|
114
|
+
const body = value === null ? Buffer.alloc(0) : Buffer.from(await new Response(value as BodyInit).arrayBuffer());
|
|
115
|
+
await write(pathFor(objects, key), body);
|
|
116
|
+
const contentType = putOptions?.httpMetadata?.contentType;
|
|
117
|
+
if (contentType) await write(pathFor(metas, key, ".json"), JSON.stringify({ contentType } satisfies Meta));
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async delete(keys) {
|
|
121
|
+
for (const key of [keys].flat()) {
|
|
122
|
+
// `force` so deleting what was never there is the success it describes.
|
|
123
|
+
await rm(pathFor(objects, key), { force: true });
|
|
124
|
+
await rm(pathFor(metas, key, ".json"), { force: true });
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|