@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.
Files changed (50) hide show
  1. package/dist/index.js +4516 -3014
  2. package/package.json +2 -2
  3. package/templates/app/.dev.vars.example +20 -0
  4. package/templates/app/app/dashboard/import-actions.ts +2 -2
  5. package/templates/app/app/dashboard/invitations/actions.ts +62 -0
  6. package/templates/app/app/dashboard/invitations/page.tsx +121 -0
  7. package/templates/app/app/invite/[token]/route.ts +28 -0
  8. package/templates/app/app/sign-up/page.tsx +61 -42
  9. package/templates/app/components/dashboard/fields/file-field.tsx +132 -12
  10. package/templates/app/components/dashboard/invite-form.tsx +86 -0
  11. package/templates/app/components/dashboard/record-detail.tsx +13 -2
  12. package/templates/app/components/dashboard/resource-form-page.tsx +9 -1
  13. package/templates/app/components/dashboard/resource-form.tsx +17 -4
  14. package/templates/app/components/dashboard/resource-table.tsx +7 -1
  15. package/templates/app/lib/api.ts +41 -3
  16. package/templates/app/lib/auth-config.ts +60 -38
  17. package/templates/app/lib/auth.ts +112 -2
  18. package/templates/app/lib/dashboard-nav.ts +5 -0
  19. package/templates/app/lib/invitations.ts +142 -0
  20. package/templates/app/lib/rate-limit.ts +71 -0
  21. package/templates/app/lib/resource/drizzle-rows.ts +7 -2
  22. package/templates/app/lib/resource/handlers.ts +9 -2
  23. package/templates/app/lib/resource/http.ts +24 -13
  24. package/templates/app/lib/resource/rows.ts +5 -1
  25. package/templates/app/lib/resource/store.ts +126 -38
  26. package/templates/app/lib/storage/cloudinary.ts +146 -0
  27. package/templates/app/lib/storage/uploadthing.ts +122 -0
  28. package/templates/app/lib/storage.ts +72 -6
  29. package/templates/app/lib/user-titles.ts +52 -0
  30. package/templates/app/worker/index.ts +4 -1
  31. package/templates/app/wrangler.jsonc +11 -0
  32. package/templates/locks/{package-lock.json → cloudflare/package-lock.json} +1674 -1997
  33. package/templates/locks/{pnpm-lock.yaml → cloudflare/pnpm-lock.yaml} +1591 -1844
  34. package/templates/locks/{yarn.lock → cloudflare/yarn.lock} +1312 -1460
  35. package/templates/locks/next/package-lock.json +13713 -0
  36. package/templates/locks/next/pnpm-lock.yaml +8252 -0
  37. package/templates/locks/next/yarn.lock +5790 -0
  38. package/templates/next/.env.example +26 -10
  39. package/templates/next/README.md +35 -3
  40. package/templates/next/docker-compose.yml +74 -0
  41. package/templates/next/lib/auth.ts +108 -2
  42. package/templates/next/lib/dashboard.ts +3 -1
  43. package/templates/next/lib/db.ts +5 -1
  44. package/templates/next/lib/invitations.ts +119 -0
  45. package/templates/next/lib/rate-limit.ts +83 -0
  46. package/templates/next/lib/resource/prisma-rows.ts +55 -11
  47. package/templates/next/lib/storage/disk.ts +128 -0
  48. package/templates/next/lib/storage/r2.ts +86 -0
  49. package/templates/next/lib/storage.ts +83 -66
  50. 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 { AwsClient } from "aws4fetch";
2
- import { createObjectKey, createStorage } from "@flaredev/core";
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
- * File storage on R2, over its S3-compatible API.
8
+ * Where uploaded files go.
6
9
  *
7
- * R2 rather than Vercel Blob for one reason: R2 charges nothing to serve what it
8
- * stores. On a file-heavy app that is the difference between a small bill and a large
9
- * one, and it costs nothing to use from outside Cloudflare — see the cost guide.
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
- * `aws4fetch` signs the requests; it is a few kilobytes and works in any runtime, unlike
12
- * the AWS SDK. Everything above this — the upload widget, the signed-URL helpers, the
13
- * file field — is the same code the Cloudflare stack runs.
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
- const endpoint = `${process.env.R2_ENDPOINT ?? ""}/${process.env.R2_BUCKET ?? ""}`;
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
- /** Raise a bucket error rather than letting a failed write look like a successful one. */
34
- async function ok(response: Response, doing: string, key: string): Promise<Response> {
35
- if (response.ok) return response;
36
- const detail = await response.text().catch(() => "");
37
- throw new Error(`Storage ${doing} failed for "${key}": ${response.status} ${detail.slice(0, 300)}`);
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
- /** The bucket, shaped the way @flaredev/core expects, over plain HTTP calls. */
41
- const bucket = {
42
- async get(key: string) {
43
- const response = await client.fetch(`${endpoint}/${path(key)}`);
44
- if (!response.ok || !response.body) return null;
45
- return {
46
- body: response.body,
47
- httpMetadata: { contentType: response.headers.get("content-type") ?? undefined },
48
- size: Number(response.headers.get("content-length") ?? 0),
49
- // R2 returns the object's ETag quoted, which is what the header wants back.
50
- httpEtag: response.headers.get("etag") ?? "",
51
- };
52
- },
53
- async put(key: string, value: ReadableStream | ArrayBuffer | string | null, options?: { httpMetadata?: { contentType?: string } }) {
54
- // S3 needs a Content-Length, and a stream has no length to send — it answers
55
- // "411 MissingContentLength" and stores nothing. Uploads are capped by the field's
56
- // own maxBytes long before they reach here, so reading one into memory is safe and
57
- // is what makes the length known.
58
- //
59
- // The length is set by hand rather than left to fetch. Next patches the global
60
- // fetch, and in that runtime it does not derive content-length even from a buffered
61
- // body — so S3 still answers 411 and stores nothing. Buffering everything through
62
- // Response keeps one path for streams, buffers and strings alike.
63
- const body = value === null ? new ArrayBuffer(0) : await new Response(value as BodyInit).arrayBuffer();
64
- const headers: Record<string, string> = { "content-length": String(body.byteLength) };
65
- if (options?.httpMetadata?.contentType) headers["content-type"] = options.httpMetadata.contentType;
66
- await ok(await client.fetch(`${endpoint}/${path(key)}`, { method: "PUT", body, headers }), "upload", key);
67
- },
68
- async delete(keys: string | string[]) {
69
- for (const key of [keys].flat()) {
70
- const response = await client.fetch(`${endpoint}/${path(key)}`, { method: "DELETE" });
71
- // S3 answers 204 for a key that was never there, which is the outcome we wanted.
72
- if (response.status !== 404) await ok(response, "delete", key);
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
+ }