@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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { AwsClient } from "aws4fetch";
|
|
2
|
+
import type { StorageBucket, StorageObjectBody } from "@flaredev/core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Files on R2, over its S3-compatible API — so this also drives any S3 that will take
|
|
6
|
+
* a signed request: MinIO in `docker compose`, Backblaze B2, S3 itself.
|
|
7
|
+
*
|
|
8
|
+
* R2 rather than Vercel Blob for one reason: R2 charges nothing to serve what it
|
|
9
|
+
* stores. On a file-heavy app that is the difference between a small bill and a large
|
|
10
|
+
* one, and it costs nothing to use from outside Cloudflare — see the cost guide.
|
|
11
|
+
*
|
|
12
|
+
* `aws4fetch` signs the requests; it is a few kilobytes and works in any runtime,
|
|
13
|
+
* unlike the AWS SDK. There is no `publicUrl`: the bucket is private, and reads are
|
|
14
|
+
* signed and proxied by this app so a policy still decides who may see a file.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface R2Options {
|
|
18
|
+
endpoint: string;
|
|
19
|
+
bucket: string;
|
|
20
|
+
accessKeyId: string;
|
|
21
|
+
secretAccessKey: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function r2Bucket(options: R2Options): StorageBucket {
|
|
25
|
+
const client = new AwsClient({
|
|
26
|
+
accessKeyId: options.accessKeyId,
|
|
27
|
+
secretAccessKey: options.secretAccessKey,
|
|
28
|
+
service: "s3",
|
|
29
|
+
region: "auto",
|
|
30
|
+
});
|
|
31
|
+
const endpoint = `${options.endpoint}/${options.bucket}`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A key as a URL path.
|
|
35
|
+
*
|
|
36
|
+
* Each segment is escaped on its own, so the slashes in "products/2026/09/a.png" stay
|
|
37
|
+
* slashes. Escaping the whole key would turn them into %2F, which is a different
|
|
38
|
+
* object name to S3 even where a particular server is forgiving about it.
|
|
39
|
+
*/
|
|
40
|
+
const path = (key: string) => key.split("/").map(encodeURIComponent).join("/");
|
|
41
|
+
|
|
42
|
+
/** Raise a bucket error rather than letting a failed write look like a successful one. */
|
|
43
|
+
const ok = async (response: Response, doing: string, key: string): Promise<Response> => {
|
|
44
|
+
if (response.ok) return response;
|
|
45
|
+
const detail = await response.text().catch(() => "");
|
|
46
|
+
throw new Error(`Storage ${doing} failed for "${key}": ${response.status} ${detail.slice(0, 300)}`);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
async get(key: string): Promise<StorageObjectBody | null> {
|
|
51
|
+
const response = await client.fetch(`${endpoint}/${path(key)}`);
|
|
52
|
+
if (!response.ok || !response.body) return null;
|
|
53
|
+
return {
|
|
54
|
+
body: response.body,
|
|
55
|
+
httpMetadata: { contentType: response.headers.get("content-type") ?? undefined },
|
|
56
|
+
size: Number(response.headers.get("content-length") ?? 0),
|
|
57
|
+
// R2 returns the object's ETag quoted, which is what the header wants back.
|
|
58
|
+
httpEtag: response.headers.get("etag") ?? "",
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async put(key, value, putOptions) {
|
|
63
|
+
// S3 needs a Content-Length, and a stream has no length to send — it answers
|
|
64
|
+
// "411 MissingContentLength" and stores nothing. Uploads are capped by the field's
|
|
65
|
+
// own maxBytes long before they reach here, so reading one into memory is safe and
|
|
66
|
+
// is what makes the length known.
|
|
67
|
+
//
|
|
68
|
+
// The length is set by hand rather than left to fetch. Next patches the global
|
|
69
|
+
// fetch, and in that runtime it does not derive content-length even from a buffered
|
|
70
|
+
// body — so S3 still answers 411 and stores nothing. Buffering everything through
|
|
71
|
+
// Response keeps one path for streams, buffers and strings alike.
|
|
72
|
+
const body = value === null ? new ArrayBuffer(0) : await new Response(value as BodyInit).arrayBuffer();
|
|
73
|
+
const headers: Record<string, string> = { "content-length": String(body.byteLength) };
|
|
74
|
+
if (putOptions?.httpMetadata?.contentType) headers["content-type"] = putOptions.httpMetadata.contentType;
|
|
75
|
+
await ok(await client.fetch(`${endpoint}/${path(key)}`, { method: "PUT", body, headers }), "upload", key);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async delete(keys) {
|
|
79
|
+
for (const key of [keys].flat()) {
|
|
80
|
+
const response = await client.fetch(`${endpoint}/${path(key)}`, { method: "DELETE" });
|
|
81
|
+
// S3 answers 204 for a key that was never there, which is the outcome we wanted.
|
|
82
|
+
if (response.status !== 404) await ok(response, "delete", key);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -1,81 +1,98 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createObjectKey, createStorage, type StorageBucket } from "@flaredev/core";
|
|
2
|
+
import { cloudinaryBucket } from "./storage/cloudinary";
|
|
3
|
+
import { diskBucket } from "./storage/disk";
|
|
4
|
+
import { r2Bucket } from "./storage/r2";
|
|
5
|
+
import { uploadThingBucket } from "./storage/uploadthing";
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
|
-
*
|
|
8
|
+
* Where uploaded files go.
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* One of four drivers, chosen by `STORAGE_DRIVER`. Everything above this line is the
|
|
11
|
+
* same whichever you pick — the `file` field, the upload widget, `createUploadUrl` and
|
|
12
|
+
* `createReadUrl` — because a driver only has to answer three questions: store these
|
|
13
|
+
* bytes, give them back, throw them away.
|
|
10
14
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
15
|
+
* r2 S3-compatible: R2, MinIO, B2, S3. The default, and what
|
|
16
|
+
* `docker compose up` gives you locally.
|
|
17
|
+
* disk The local filesystem, like Laravel's `local` disk. No account,
|
|
18
|
+
* no network. One machine only.
|
|
19
|
+
* cloudinary A CDN with an image pipeline. Serves from res.cloudinary.com.
|
|
20
|
+
* uploadthing A CDN with an uploader. Serves from <app>.ufs.sh.
|
|
21
|
+
*
|
|
22
|
+
* The drivers are in `lib/storage/`, in your repository, and are ordinary files you can
|
|
23
|
+
* read and change. Adding a fifth means writing `get`, `put` and `delete` — see
|
|
24
|
+
* `lib/storage/disk.ts`, which is the shortest of them.
|
|
25
|
+
*
|
|
26
|
+
* ## Private by default, and what breaks that
|
|
27
|
+
*
|
|
28
|
+
* With `r2` or `disk`, a read URL is a short-lived signed token redeemed by
|
|
29
|
+
* `app/api/storage/route.ts`, so whoever issued it decided the viewer was allowed it.
|
|
30
|
+
*
|
|
31
|
+
* `cloudinary` and `uploadthing` are content delivery networks, and serving through
|
|
32
|
+
* this app would throw away the thing you are paying them for. So they hand back their
|
|
33
|
+
* own URL, which **anyone with the link can read, forever**. That is the normal way to
|
|
34
|
+
* use them and the wrong way to store a medical record. Set `STORAGE_PUBLIC=false` to
|
|
35
|
+
* keep every read signed and proxied, and they become ordinary private storage.
|
|
36
|
+
*
|
|
37
|
+
* const { url } = await storage.createUploadUrl({
|
|
38
|
+
* key: createObjectKey(file.name, `users/${user.id}`),
|
|
39
|
+
* contentTypes: ["image/*", "application/pdf"],
|
|
40
|
+
* maxBytes: 10 * 1024 * 1024,
|
|
41
|
+
* });
|
|
42
|
+
* // browser: fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } })
|
|
43
|
+
*
|
|
44
|
+
* const src = await storage.createReadUrl({ key });
|
|
14
45
|
*/
|
|
15
|
-
const client = new AwsClient({
|
|
16
|
-
accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
|
|
17
|
-
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
|
|
18
|
-
service: "s3",
|
|
19
|
-
region: "auto",
|
|
20
|
-
});
|
|
21
46
|
|
|
22
|
-
|
|
47
|
+
type StorageDriver = "r2" | "disk" | "cloudinary" | "uploadthing";
|
|
23
48
|
|
|
24
|
-
|
|
25
|
-
* A key as a URL path.
|
|
26
|
-
*
|
|
27
|
-
* Each segment is escaped on its own, so the slashes in "products/2026/09/a.png" stay
|
|
28
|
-
* slashes. Escaping the whole key would turn them into %2F, which is a different object
|
|
29
|
-
* name to S3 even where a particular server is forgiving about it.
|
|
30
|
-
*/
|
|
31
|
-
const path = (key: string) => key.split("/").map(encodeURIComponent).join("/");
|
|
49
|
+
const DRIVERS: StorageDriver[] = ["r2", "disk", "cloudinary", "uploadthing"];
|
|
32
50
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
/** Required environment, named in the error rather than left as `undefined`. */
|
|
52
|
+
function required(name: string): string {
|
|
53
|
+
const value = process.env[name];
|
|
54
|
+
if (!value) throw new Error(`STORAGE_DRIVER is "${driver}" and needs ${name}, which is not set.`);
|
|
55
|
+
return value;
|
|
38
56
|
}
|
|
39
57
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
};
|
|
58
|
+
const configured = (process.env.STORAGE_DRIVER ?? "r2").trim().toLowerCase();
|
|
59
|
+
if (!DRIVERS.includes(configured as StorageDriver)) {
|
|
60
|
+
throw new Error(`Unknown STORAGE_DRIVER "${configured}". One of: ${DRIVERS.join(", ")}.`);
|
|
61
|
+
}
|
|
62
|
+
const driver = configured as StorageDriver;
|
|
63
|
+
|
|
64
|
+
/** Public delivery is the point of a CDN, so it is on unless it is turned off. */
|
|
65
|
+
const publicDelivery = process.env.STORAGE_PUBLIC !== "false";
|
|
66
|
+
|
|
67
|
+
function selectBucket(): StorageBucket {
|
|
68
|
+
switch (driver) {
|
|
69
|
+
case "disk":
|
|
70
|
+
return diskBucket({ dir: process.env.STORAGE_DIR });
|
|
71
|
+
case "cloudinary":
|
|
72
|
+
return cloudinaryBucket({
|
|
73
|
+
cloudName: required("CLOUDINARY_CLOUD_NAME"),
|
|
74
|
+
apiKey: required("CLOUDINARY_API_KEY"),
|
|
75
|
+
apiSecret: required("CLOUDINARY_API_SECRET"),
|
|
76
|
+
publicDelivery,
|
|
77
|
+
});
|
|
78
|
+
case "uploadthing":
|
|
79
|
+
return uploadThingBucket({
|
|
80
|
+
token: required("UPLOADTHING_TOKEN"),
|
|
81
|
+
appId: required("UPLOADTHING_APP_ID"),
|
|
82
|
+
publicDelivery,
|
|
83
|
+
});
|
|
84
|
+
case "r2":
|
|
85
|
+
return r2Bucket({
|
|
86
|
+
endpoint: required("R2_ENDPOINT"),
|
|
87
|
+
bucket: required("R2_BUCKET"),
|
|
88
|
+
accessKeyId: required("R2_ACCESS_KEY_ID"),
|
|
89
|
+
secretAccessKey: required("R2_SECRET_ACCESS_KEY"),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
76
93
|
|
|
77
94
|
export const storage = createStorage({
|
|
78
|
-
bucket,
|
|
95
|
+
bucket: selectBucket(),
|
|
79
96
|
// Signing keys are derived from the auth secret with a storage-specific label.
|
|
80
97
|
secret: process.env.BETTER_AUTH_SECRET ?? "",
|
|
81
98
|
});
|
|
@@ -0,0 +1,49 @@
|
|
|
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 — the Prisma half.
|
|
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 { prisma } from "@/lib/db";
|
|
22
|
+
|
|
23
|
+
/** What the admin needs to treat Better Auth's account table as a relation target. */
|
|
24
|
+
export const AUTH_USER_RELATION = {
|
|
25
|
+
name: "User",
|
|
26
|
+
label: "User",
|
|
27
|
+
pluralLabel: "Users",
|
|
28
|
+
slug: "users",
|
|
29
|
+
titleField: "name",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/** id → display name, for the ids that exist. Unknown ids are simply absent. */
|
|
33
|
+
export async function userTitles(ids: string[]): Promise<Record<string, string>> {
|
|
34
|
+
const wanted = [...new Set(ids.filter(Boolean))];
|
|
35
|
+
if (wanted.length === 0) return {};
|
|
36
|
+
const rows = await prisma.user.findMany({ where: { id: { in: wanted } }, select: { id: true, name: true, email: true } });
|
|
37
|
+
return Object.fromEntries(rows.map((row) => [row.id, row.name?.trim() || row.email]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether anybody has an account yet.
|
|
42
|
+
*
|
|
43
|
+
* Asked by the sign-up page: the gate in lib/auth.ts always lets the *first* account
|
|
44
|
+
* through, whatever `authConfig.signup` says, because an app nobody can sign into cannot
|
|
45
|
+
* be set up. So "sign-up is closed" is only true once somebody exists.
|
|
46
|
+
*/
|
|
47
|
+
export async function anyAccountExists(): Promise<boolean> {
|
|
48
|
+
return (await prisma.user.findFirst({ select: { id: true } })) !== null;
|
|
49
|
+
}
|