@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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|