@flaredev/cli 0.11.0 → 0.12.1
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 +4858 -3395
- package/package.json +2 -2
- package/templates/app/.dev.vars.example +19 -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 +160 -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 +571 -691
- package/templates/locks/cloudflare/pnpm-lock.yaml +1065 -1095
- package/templates/locks/cloudflare/yarn.lock +363 -381
- package/templates/locks/next/package-lock.json +284 -243
- package/templates/locks/next/pnpm-lock.yaml +898 -878
- 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
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
import { env } from "cloudflare:workers";
|
|
2
|
-
import { createObjectKey, createStorage } from "@flaredev/core";
|
|
2
|
+
import { createObjectKey, createStorage, type StorageBucket } from "@flaredev/core";
|
|
3
|
+
import { cloudinaryBucket } from "./storage/cloudinary";
|
|
4
|
+
import { uploadThingBucket } from "./storage/uploadthing";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
|
-
*
|
|
7
|
+
* Where uploaded files go.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* The default is the `STORAGE` R2 bucket this app is already bound to: no account to
|
|
10
|
+
* create, no keys to copy, and Wrangler simulates it locally as a directory. Set
|
|
11
|
+
* `STORAGE_DRIVER` to send files to a content delivery network instead.
|
|
12
|
+
*
|
|
13
|
+
* r2 The bound bucket. The default, and nothing to configure.
|
|
14
|
+
* cloudinary A CDN with an image pipeline. Serves from res.cloudinary.com.
|
|
15
|
+
* uploadthing A CDN with an uploader. Serves from <app>.ufs.sh.
|
|
16
|
+
*
|
|
17
|
+
* The drivers are in `lib/storage/`, in your repository, and are ordinary files you can
|
|
18
|
+
* read and change. Adding one means writing `get`, `put` and `delete`.
|
|
19
|
+
*
|
|
20
|
+
* `disk` exists on the Next.js stack and not here: a Worker has no filesystem to write
|
|
21
|
+
* to, and the bound bucket already covers what it is for.
|
|
22
|
+
*
|
|
23
|
+
* ## Private by default, and what breaks that
|
|
24
|
+
*
|
|
25
|
+
* With `r2`, a read URL is a short-lived signed token redeemed by
|
|
26
|
+
* `app/api/storage/route.ts`, so whoever issued it decided the viewer was allowed it.
|
|
27
|
+
*
|
|
28
|
+
* `cloudinary` and `uploadthing` are content delivery networks, and serving through
|
|
29
|
+
* this Worker would throw away the thing you are paying them for. So they hand back
|
|
30
|
+
* their own URL, which **anyone with the link can read, forever**. That is the normal
|
|
31
|
+
* way to use them and the wrong way to store a medical record. Set
|
|
32
|
+
* `STORAGE_PUBLIC=false` to keep every read signed and proxied.
|
|
10
33
|
*
|
|
11
34
|
* const { url } = await storage.createUploadUrl({
|
|
12
35
|
* key: createObjectKey(file.name, `users/${user.id}`),
|
|
@@ -17,8 +40,51 @@ import { createObjectKey, createStorage } from "@flaredev/core";
|
|
|
17
40
|
*
|
|
18
41
|
* const src = await storage.createReadUrl({ key });
|
|
19
42
|
*/
|
|
43
|
+
|
|
44
|
+
type StorageDriver = "r2" | "cloudinary" | "uploadthing";
|
|
45
|
+
|
|
46
|
+
const DRIVERS: StorageDriver[] = ["r2", "cloudinary", "uploadthing"];
|
|
47
|
+
|
|
48
|
+
const vars = env as unknown as Record<string, string | undefined>;
|
|
49
|
+
|
|
50
|
+
const configured = (vars.STORAGE_DRIVER ?? "r2").trim().toLowerCase();
|
|
51
|
+
if (!DRIVERS.includes(configured as StorageDriver)) {
|
|
52
|
+
throw new Error(`Unknown STORAGE_DRIVER "${configured}". One of: ${DRIVERS.join(", ")}.`);
|
|
53
|
+
}
|
|
54
|
+
const driver = configured as StorageDriver;
|
|
55
|
+
|
|
56
|
+
/** Required configuration, named in the error rather than left as `undefined`. */
|
|
57
|
+
function required(name: string): string {
|
|
58
|
+
const value = vars[name];
|
|
59
|
+
if (!value) throw new Error(`STORAGE_DRIVER is "${driver}" and needs ${name}, which is not set. Add it with: wrangler secret put ${name}`);
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Public delivery is the point of a CDN, so it is on unless it is turned off. */
|
|
64
|
+
const publicDelivery = vars.STORAGE_PUBLIC !== "false";
|
|
65
|
+
|
|
66
|
+
function selectBucket(): StorageBucket {
|
|
67
|
+
switch (driver) {
|
|
68
|
+
case "cloudinary":
|
|
69
|
+
return cloudinaryBucket({
|
|
70
|
+
cloudName: required("CLOUDINARY_CLOUD_NAME"),
|
|
71
|
+
apiKey: required("CLOUDINARY_API_KEY"),
|
|
72
|
+
apiSecret: required("CLOUDINARY_API_SECRET"),
|
|
73
|
+
publicDelivery,
|
|
74
|
+
});
|
|
75
|
+
case "uploadthing":
|
|
76
|
+
return uploadThingBucket({
|
|
77
|
+
// The app id lives inside the token; the driver reads it from there.
|
|
78
|
+
token: required("UPLOADTHING_TOKEN"),
|
|
79
|
+
publicDelivery,
|
|
80
|
+
});
|
|
81
|
+
case "r2":
|
|
82
|
+
return env.STORAGE;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
20
86
|
export const storage = createStorage({
|
|
21
|
-
bucket:
|
|
87
|
+
bucket: selectBucket(),
|
|
22
88
|
// Signing keys are derived from the auth secret with a storage-specific label.
|
|
23
89
|
secret: env.BETTER_AUTH_SECRET,
|
|
24
90
|
});
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
* Names for the accounts a `belongsTo("User")` field points at.
|
|
12
|
+
*
|
|
13
|
+
* Better Auth owns the account table, so it is not a Flare resource and has no store to
|
|
14
|
+
* ask — but the admin still has to show "Ada Lovelace" where the column holds a uuid.
|
|
15
|
+
* This is the one place that reads it, and it reads two columns and no more.
|
|
16
|
+
*
|
|
17
|
+
* An account may have no name (a passkey-only sign-up, say), so the email stands in. It
|
|
18
|
+
* is the same fallback `/api/users` uses, so a record reads the same in the table, on
|
|
19
|
+
* the detail page and in the picker.
|
|
20
|
+
*/
|
|
21
|
+
import { inArray } from "drizzle-orm";
|
|
22
|
+
import { getDb } from "@/db";
|
|
23
|
+
import { user } from "@/db/auth-schema";
|
|
24
|
+
|
|
25
|
+
/** What the admin needs to treat Better Auth's account table as a relation target. */
|
|
26
|
+
export const AUTH_USER_RELATION = {
|
|
27
|
+
name: "User",
|
|
28
|
+
label: "User",
|
|
29
|
+
pluralLabel: "Users",
|
|
30
|
+
slug: "users",
|
|
31
|
+
titleField: "name",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
/** id → display name, for the ids that exist. Unknown ids are simply absent. */
|
|
35
|
+
export async function userTitles(ids: string[]): Promise<Record<string, string>> {
|
|
36
|
+
const wanted = [...new Set(ids.filter(Boolean))];
|
|
37
|
+
if (wanted.length === 0) return {};
|
|
38
|
+
const rows = await getDb().select({ id: user.id, name: user.name, email: user.email }).from(user).where(inArray(user.id, wanted));
|
|
39
|
+
return Object.fromEntries(rows.map((row) => [row.id, row.name?.trim() || row.email]));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether anybody has an account yet.
|
|
44
|
+
*
|
|
45
|
+
* Asked by the sign-up page: the gate in lib/auth.ts always lets the *first* account
|
|
46
|
+
* through, whatever `authConfig.signup` says, because an app nobody can sign into cannot
|
|
47
|
+
* be set up. So "sign-up is closed" is only true once somebody exists.
|
|
48
|
+
*/
|
|
49
|
+
export async function anyAccountExists(): Promise<boolean> {
|
|
50
|
+
const [first] = await getDb().select({ id: user.id }).from(user).limit(1);
|
|
51
|
+
return first !== undefined;
|
|
52
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import app from "vinext/server/app-router-entry";
|
|
2
|
+
import { RateLimitCounter } from "@flaredev/core/rate-limit/server";
|
|
2
3
|
import { RealtimeChannel, handleRealtimeUpgrade } from "@flaredev/core/realtime/server";
|
|
3
4
|
import { SecurityMonitor } from "@flaredev/core/security/server";
|
|
4
5
|
import { authorizeRealtime } from "../lib/realtime";
|
|
@@ -9,12 +10,14 @@ import { protect } from "../lib/security";
|
|
|
9
10
|
// the `cloudflare:workers` module.
|
|
10
11
|
|
|
11
12
|
// Durable Object classes must be exported from the Worker's main module.
|
|
12
|
-
export { RealtimeChannel, SecurityMonitor };
|
|
13
|
+
export { RateLimitCounter, RealtimeChannel, SecurityMonitor };
|
|
13
14
|
|
|
14
15
|
export interface AppEnv {
|
|
15
16
|
ASSETS: Fetcher;
|
|
16
17
|
DB: D1Database;
|
|
17
18
|
FLARE_REALTIME: DurableObjectNamespace<RealtimeChannel>;
|
|
19
|
+
/** Counters for the rate limits a descriptor declares (see lib/rate-limit.ts). */
|
|
20
|
+
FLARE_RATE_LIMIT: DurableObjectNamespace<RateLimitCounter>;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export interface AppContext {
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// Object instance created on demand; wrangler provisions nothing upfront.
|
|
17
17
|
"name": "FLARE_REALTIME",
|
|
18
18
|
"class_name": "RealtimeChannel"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
// Rate limit counters (see lib/rate-limit.ts). One instance per resource and
|
|
22
|
+
// action, holding a count per caller — so a limited endpoint costs one object
|
|
23
|
+
// request, not one per person calling it.
|
|
24
|
+
"name": "FLARE_RATE_LIMIT",
|
|
25
|
+
"class_name": "RateLimitCounter"
|
|
19
26
|
}
|
|
20
27
|
]
|
|
21
28
|
},
|
|
@@ -23,6 +30,10 @@
|
|
|
23
30
|
{
|
|
24
31
|
"tag": "v1",
|
|
25
32
|
"new_sqlite_classes": ["RealtimeChannel"]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"tag": "v2",
|
|
36
|
+
"new_sqlite_classes": ["RateLimitCounter"]
|
|
26
37
|
}
|
|
27
38
|
],
|
|
28
39
|
"d1_databases": [
|