@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,86 @@
1
+ "use client";
2
+
3
+ import { useState, useTransition } from "react";
4
+ import { CheckIcon, CopyIcon } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
7
+ import { Input } from "@/components/ui/input";
8
+ import { Spinner } from "@/components/ui/spinner";
9
+ import type { IssuedInvitation } from "@/app/dashboard/invitations/actions";
10
+
11
+ /**
12
+ * Issue an invitation and show the link once.
13
+ *
14
+ * Once, because only the hash of the token is stored: there is no second chance to read
15
+ * it. So the link stays on screen until the next one is issued, with a copy button — a
16
+ * link you have to retype from a toast that has already gone is not a feature.
17
+ */
18
+ export function InviteForm({ issue }: { issue: (formData: FormData) => Promise<IssuedInvitation> }) {
19
+ const [pending, startTransition] = useTransition();
20
+ const [issued, setIssued] = useState<IssuedInvitation | null>(null);
21
+ const [error, setError] = useState<string | null>(null);
22
+ const [copied, setCopied] = useState(false);
23
+
24
+ const submit = (formData: FormData) => {
25
+ setError(null);
26
+ startTransition(async () => {
27
+ try {
28
+ setIssued(await issue(formData));
29
+ setCopied(false);
30
+ } catch (cause) {
31
+ setError(cause instanceof Error ? cause.message : "Could not create the invitation.");
32
+ }
33
+ });
34
+ };
35
+
36
+ const copy = async () => {
37
+ if (!issued) return;
38
+ try {
39
+ await navigator.clipboard.writeText(issued.link);
40
+ setCopied(true);
41
+ } catch {
42
+ // A browser that refuses the clipboard still shows the link to select by hand.
43
+ }
44
+ };
45
+
46
+ return (
47
+ <div className="flex flex-col gap-4">
48
+ <form action={submit}>
49
+ <FieldGroup>
50
+ <Field>
51
+ <FieldLabel htmlFor="invite-email">Email address</FieldLabel>
52
+ <Input id="invite-email" name="email" type="email" placeholder="ada@example.com" autoComplete="off" />
53
+ <FieldDescription>Leave it empty for a link anybody may use.</FieldDescription>
54
+ </Field>
55
+ <Field>
56
+ <FieldLabel htmlFor="invite-days">Valid for</FieldLabel>
57
+ <Input id="invite-days" name="days" type="number" min={1} max={90} defaultValue={7} />
58
+ <FieldDescription>Days until the link stops working.</FieldDescription>
59
+ </Field>
60
+ <Button type="submit" disabled={pending}>
61
+ {pending && <Spinner data-icon="inline-start" />}
62
+ Create invitation
63
+ </Button>
64
+ </FieldGroup>
65
+ </form>
66
+
67
+ {error && <p className="text-sm text-destructive">{error}</p>}
68
+
69
+ {issued && (
70
+ <div className="flex flex-col gap-2 rounded-md border bg-muted/40 p-3">
71
+ <p className="text-sm font-medium">
72
+ {issued.email ? `Invitation for ${issued.email}` : "Invitation for anybody with the link"}
73
+ </p>
74
+ <div className="flex items-center gap-2">
75
+ <code className="min-w-0 flex-1 truncate rounded bg-background px-2 py-1 text-xs">{issued.link}</code>
76
+ <Button type="button" variant="outline" size="sm" onClick={copy}>
77
+ {copied ? <CheckIcon data-icon="inline-start" /> : <CopyIcon data-icon="inline-start" />}
78
+ {copied ? "Copied" : "Copy"}
79
+ </Button>
80
+ </div>
81
+ <p className="text-xs text-muted-foreground">Copy it now — it is not stored and cannot be shown again.</p>
82
+ </div>
83
+ )}
84
+ </div>
85
+ );
86
+ }
@@ -1,6 +1,6 @@
1
1
  import Link from "next/link";
2
2
  import { notFound } from "next/navigation";
3
- import { clientResource, formatValue, optionLabel, relationGraph, statusTone, storedFields, type Resource } from "@flaredev/core";
3
+ import { clientResource, formatValue, isAuthUserTarget, optionLabel, relationGraph, statusTone, storedFields, type Resource } from "@flaredev/core";
4
4
  import { Badge } from "@/components/ui/badge";
5
5
  import { FileCell } from "@/components/dashboard/fields/file-cell";
6
6
  import { Markdown } from "@/components/dashboard/fields/markdown";
@@ -13,6 +13,7 @@ import { LocalTime } from "./local-time";
13
13
  import { PageHeader } from "./page-header";
14
14
  import { RecordActions } from "./record-actions";
15
15
  import type { RelationMeta } from "./fields/field-widget";
16
+ import { AUTH_USER_RELATION, userTitles } from "@/lib/user-titles";
16
17
 
17
18
  /** How many of a record's children to show before pointing at the filtered list. */
18
19
  const RELATED_LIMIT = 5;
@@ -43,10 +44,20 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
43
44
  const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
44
45
  for (const [key, def] of fields) {
45
46
  if (def.kind !== "belongsTo") continue;
47
+ const value = record[key];
48
+ // Better Auth's account table is not a resource, so it has no store to ask: its
49
+ // names come from lib/user-titles.ts and its picker from /api/users.
50
+ if (isAuthUserTarget(def.target)) {
51
+ relations[key] = AUTH_USER_RELATION;
52
+ if (typeof value === "string") {
53
+ const title = (await userTitles([value]))[value];
54
+ if (title) relationTitles[key] = title;
55
+ }
56
+ continue;
57
+ }
46
58
  const target = byName.get(def.target);
47
59
  if (!target) continue;
48
60
  relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
49
- const value = record[key];
50
61
  if (typeof value === "string") {
51
62
  const titles = await dashboardStore(target.name).titles([value]);
52
63
  const title = titles[value];
@@ -1,9 +1,10 @@
1
1
  import { notFound } from "next/navigation";
2
- import { clientResource, formatValue, storedFields, type Resource } from "@flaredev/core";
2
+ import { clientResource, formatValue, isAuthUserTarget, storedFields, type Resource } from "@flaredev/core";
3
3
  import { resourcePath, allResources, confinedField, dashboardStore, requireAccess } from "@/lib/dashboard";
4
4
  import type { RelationMeta } from "./fields/field-widget";
5
5
  import { PageHeader } from "./page-header";
6
6
  import { ResourceForm } from "./resource-form";
7
+ import { AUTH_USER_RELATION, userTitles } from "@/lib/user-titles";
7
8
 
8
9
  /** Server wrapper for create/edit pages: loads the record and relation titles, renders the heading and form. */
9
10
  export async function ResourceFormPage({ resource, id }: { resource: Resource; id?: string }) {
@@ -25,6 +26,13 @@ export async function ResourceFormPage({ resource, id }: { resource: Resource; i
25
26
  const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
26
27
  for (const [key, def] of storedFields(resource)) {
27
28
  if (def.kind !== "belongsTo") continue;
29
+ if (isAuthUserTarget(def.target)) {
30
+ // The accounts table, whose names and picker come from outside the resource store.
31
+ const current = record?.[key];
32
+ const titles = typeof current === "string" ? await userTitles([current]) : {};
33
+ relations[key] = { ...AUTH_USER_RELATION, initialTitle: typeof current === "string" ? titles[current] : undefined };
34
+ continue;
35
+ }
28
36
  const target = byName.get(def.target);
29
37
  if (!target) continue;
30
38
  const value = record?.[key];
@@ -3,7 +3,7 @@
3
3
  import Link from "next/link";
4
4
  import { useRouter } from "next/navigation";
5
5
  import { useMemo, useState, useTransition, type FormEvent, type MouseEvent } from "react";
6
- import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
6
+ import { conditionHolds, createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
7
7
  import { toast } from "sonner";
8
8
  import { createRecordAction, updateRecordAction } from "@/app/dashboard/actions";
9
9
  import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -69,10 +69,21 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
69
69
  const router = useRouter();
70
70
  const validators = useMemo(() => createValidators(resource), [resource]);
71
71
  const hidden = useMemo(() => new Set(omit ?? []), [omit]);
72
- const fields = useMemo(() => storedFields(resource).filter(([key]) => !hidden.has(key)), [resource, hidden]);
73
- const steps = useMemo(() => toSteps(fields), [fields]);
74
72
  const [step, setStep] = useState(0);
75
73
  const [values, setValues] = useState(() => initialFormValues(resource, record));
74
+ /**
75
+ * The fields on show. A conditional field appears only while its condition holds, so
76
+ * this is recomputed as the form is filled in: choose "recurring" and the billing cycle
77
+ * appears, choose "one-off" and it goes away again.
78
+ *
79
+ * The same condition decides what the server requires, so a field that is not on show
80
+ * is never one the save is about to fail on.
81
+ */
82
+ const fields = useMemo(
83
+ () => storedFields(resource).filter(([key, def]) => !hidden.has(key) && conditionHolds(def.showWhen, values)),
84
+ [resource, hidden, values],
85
+ );
86
+ const steps = useMemo(() => toSteps(fields), [fields]);
76
87
  const [errors, setErrors] = useState<Record<string, string>>({});
77
88
  const [pending, startTransition] = useTransition();
78
89
 
@@ -139,7 +150,9 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
139
150
  router.refresh();
140
151
  return;
141
152
  }
142
- const fieldErrors = result.issues ? issuesByField(result.issues) : result.field ? { [result.field]: result.error } : { _form: result.error };
153
+ // One shape to read: a unique conflict names its column in `issues` now, the same
154
+ // way a failed rule does, so there is no second path for it.
155
+ const fieldErrors = result.issues?.length ? issuesByField(result.issues) : { _form: result.error };
143
156
  setErrors(fieldErrors);
144
157
  const broken = steps.findIndex((entries) => entries.some(([key]) => fieldErrors[key]));
145
158
  if (broken >= 0 && broken !== step) setStep(broken);
@@ -1,7 +1,7 @@
1
1
  import Link from "next/link";
2
2
  import { FileCell } from "@/components/dashboard/fields/file-cell";
3
3
  import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
4
- import { clientResource, formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
4
+ import { clientResource, formatValue, isAuthUserTarget, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
5
5
  import { isSortable } from "@/lib/resource/query";
6
6
  import { Alert, AlertDescription } from "@/components/ui/alert";
7
7
  import { Badge } from "@/components/ui/badge";
@@ -33,6 +33,7 @@ import { ResourceTableToolbar } from "./resource-table-toolbar";
33
33
  import { SaveViewButton } from "./save-view-button";
34
34
  import { RowActions } from "./row-actions";
35
35
  import { RowCheckbox, SelectAllCheckbox, SelectionBar, TableSelection } from "./table-selection";
36
+ import { AUTH_USER_RELATION } from "@/lib/user-titles";
36
37
 
37
38
  type Column = { key: string; label: string; def: StoredField | { kind: "timestamp" } };
38
39
 
@@ -92,6 +93,11 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
92
93
  const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
93
94
  for (const [key, def] of fields) {
94
95
  if (def.kind !== "belongsTo") continue;
96
+ if (isAuthUserTarget(def.target)) {
97
+ // The picker fills its own title from /api/users/<id>, so no lookup is needed here.
98
+ relations[key] = AUTH_USER_RELATION;
99
+ continue;
100
+ }
95
101
  const target = byName.get(def.target);
96
102
  if (!target) continue;
97
103
  relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
@@ -1,5 +1,9 @@
1
1
  import { headers } from "next/headers";
2
- import { can, type Policy } from "@flaredev/core";
2
+ import { can, type Policy, rateFor, type Resource } from "@flaredev/core";
3
+ import { PROBLEM_CODES, problemResponse } from "@flaredev/core";
4
+ import { clientIp } from "@flaredev/core/security";
5
+ import type { ResourceAction } from "@/lib/resource";
6
+ import { checkRateLimit } from "@/lib/rate-limit";
3
7
  import type { Authorize } from "@/lib/resource/handlers";
4
8
  import { policies } from "@/policies";
5
9
  import { userFromApiKey } from "./api-keys";
@@ -53,10 +57,44 @@ export function policyFor(resourceName: string): Policy | undefined {
53
57
  export const authorize: Authorize = async ({ request, resource, action }) => {
54
58
  const session = await auth.api.getSession({ headers: request.headers });
55
59
  const viaKey = session ? null : await userFromApiKey(request.headers);
56
- if (!session && !viaKey) return Response.json({ error: "Sign in required." }, { status: 401 });
60
+ if (!session && !viaKey) return problemResponse(401, "Sign in required.", PROBLEM_CODES.unauthenticated);
57
61
 
58
62
  const role = session ? ((session.user as { role?: string | null }).role ?? null) : viaKey!.role;
59
63
  if (!can(policyFor(resource.name), role, action)) {
60
- return Response.json({ error: `Your role can't ${action} ${resource.pluralLabel.toLowerCase()}.` }, { status: 403 });
64
+ return problemResponse(403, `Your role can't ${action} ${resource.pluralLabel.toLowerCase()}.`, PROBLEM_CODES.forbidden);
61
65
  }
62
66
  };
67
+
68
+ /**
69
+ * The rate limit a resource declares for this action, enforced.
70
+ *
71
+ * Returns a 429 to send back, or nothing when the call is within the limit — the same
72
+ * shape as `authorize`, so a route reads the two the same way.
73
+ *
74
+ * Counted per caller: the signed-in account where there is one, the client IP otherwise.
75
+ * So a limit on `create` is a limit on that person rather than on everyone sharing an
76
+ * office connection, and anonymous callers behind one address share a bucket, which is
77
+ * the right trade for an endpoint that does not know who is calling.
78
+ *
79
+ * Checked after `authorize`, deliberately. A request that is going to be refused anyway
80
+ * should not use up somebody's quota, and a flood with no session is the edge limiter's
81
+ * job (`flare gen security`) rather than this one's.
82
+ */
83
+ export async function rateLimitGuard(context: { request: Request; resource: Resource; action: ResourceAction }): Promise<Response | undefined> {
84
+ const rate = rateFor(context.resource.rateLimit, context.action);
85
+ if (!rate) return undefined;
86
+
87
+ const account = await currentUser();
88
+ const caller = account?.id ?? clientIp(context.request) ?? "unknown";
89
+ const decision = await checkRateLimit(context.resource.name, context.action, caller, rate);
90
+ if (decision.allowed) return undefined;
91
+
92
+ return problemResponse(429, "Too many requests. Try again shortly.", PROBLEM_CODES.rateLimited, undefined, {
93
+ headers: {
94
+ "retry-after": String(decision.retryAfter),
95
+ // What the limit was, so a client can pace itself rather than guess.
96
+ "x-ratelimit-limit": String(rate.limit),
97
+ "x-ratelimit-remaining": "0",
98
+ },
99
+ });
100
+ }
@@ -1,38 +1,60 @@
1
- /**
2
- * How people sign in to this app. `flare create` wrote the choices you made; change
3
- * them here any time. Every method's tables already exist, so switching one on or off
4
- * needs no migration, and a method that's off is refused by the API, not just hidden.
5
- *
6
- * Email and password sign-in is always available.
7
- */
8
- export const authConfig = {
9
- /** A sign-in link by email. */
10
- magicLink: true,
11
- /** A 6-digit sign-in code by email. */
12
- emailOtp: true,
13
- /** Face ID, Touch ID, Windows Hello or a security key. */
14
- passkeys: true,
15
- twoFactor: {
16
- /** Codes from an authenticator app (TOTP), with backup codes. */
17
- authenticator: true,
18
- /** Codes by email as the second step. */
19
- email: true,
20
- },
21
- /**
22
- * Social sign-in. Each provider also needs its credentials (see .dev.vars.example);
23
- * without them its button stays hidden.
24
- */
25
- social: [] as SocialProvider[],
26
- /** Refuse password sign-in until the email address is verified. */
27
- requireEmailVerification: false,
28
- /**
29
- * Check new passwords against Have I Been Pwned's breach list. Only the first five
30
- * characters of the password's hash are sent, and an outage lets the password through.
31
- */
32
- checkBreachedPasswords: true,
33
- };
34
-
35
- export type SocialProvider = "google" | "github" | "apple" | "microsoft";
36
-
37
- /** Whether any second factor can be set up. */
38
- export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
1
+ /**
2
+ * How people sign in to this app. `flare create` wrote the choices you made; change
3
+ * them here any time. Every method's tables already exist, so switching one on or off
4
+ * needs no migration, and a method that's off is refused by the API, not just hidden.
5
+ *
6
+ * Email and password sign-in is always available.
7
+ */
8
+ export const authConfig = {
9
+ /**
10
+ * Who may create an account.
11
+ *
12
+ * - `open` — anybody who finds the sign-up page. Right for a public product.
13
+ * - `invite` — only somebody holding an invitation link. An admin issues them from
14
+ * /dashboard/invitations; see lib/invitations.ts.
15
+ * - `closed` — nobody. Accounts are made with `flare user:add <email>`.
16
+ *
17
+ * The first account is always allowed, whatever this says: an app nobody can sign into
18
+ * cannot be set up, and in `invite` mode there would be nobody to issue the invitation.
19
+ * Every later sign-up goes through this.
20
+ *
21
+ * Enforced where the account is created rather than on each sign-up route, so it holds
22
+ * for a password sign-up, a magic link, an email code and a social callback alike.
23
+ */
24
+ signup: "__SIGNUP__" as SignupMode,
25
+ /** A sign-in link by email. */
26
+ magicLink: true,
27
+ /** A 6-digit sign-in code by email. */
28
+ emailOtp: true,
29
+ /** Face ID, Touch ID, Windows Hello or a security key. */
30
+ passkeys: true,
31
+ twoFactor: {
32
+ /** Codes from an authenticator app (TOTP), with backup codes. */
33
+ authenticator: true,
34
+ /** Codes by email as the second step. */
35
+ email: true,
36
+ },
37
+ /**
38
+ * Social sign-in. Each provider also needs its credentials (see .dev.vars.example);
39
+ * without them its button stays hidden.
40
+ */
41
+ social: [] as SocialProvider[],
42
+ /** Refuse password sign-in until the email address is verified. */
43
+ requireEmailVerification: false,
44
+ /**
45
+ * Check new passwords against Have I Been Pwned's breach list. Only the first five
46
+ * characters of the password's hash are sent, and an outage lets the password through.
47
+ */
48
+ checkBreachedPasswords: true,
49
+ };
50
+
51
+ export type SocialProvider = "google" | "github" | "apple" | "microsoft";
52
+
53
+ /** Who may create an account. See `authConfig.signup`. */
54
+ export type SignupMode = "open" | "invite" | "closed";
55
+
56
+ /** Whether the sign-up page should be offered at all. */
57
+ export const signupOffered = authConfig.signup !== "closed";
58
+
59
+ /** Whether any second factor can be set up. */
60
+ export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
@@ -1,4 +1,6 @@
1
1
  import { env, waitUntil } from "cloudflare:workers";
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 { drizzleAdapter } from "@better-auth/drizzle-adapter";
4
6
  import { betterAuth } from "better-auth";
@@ -9,6 +11,7 @@ import { emailOTP } from "better-auth/plugins/email-otp";
9
11
  import { magicLink } from "better-auth/plugins/magic-link";
10
12
  import { twoFactor } from "better-auth/plugins/two-factor";
11
13
  import { hashPassword, verifyPassword } from "@flaredev/core";
14
+ import { eq } from "drizzle-orm";
12
15
  import { getDb, schema } from "@/db";
13
16
  import { authConfig, type SocialProvider } from "./auth-config";
14
17
  import { checkPassword, isBreachedPassword, PASSWORD_MAX_LENGTH } from "./password-rules";
@@ -107,6 +110,89 @@ async function guardPassword(path: string, body: Record<string, unknown> | undef
107
110
  }
108
111
  }
109
112
 
113
+ /**
114
+ * Whether this account may be created at all — `authConfig.signup`.
115
+ *
116
+ * Checked where the user row is written rather than on each sign-up route, because there
117
+ * are four ways to end up with a new account (a password sign-up, a magic link, an email
118
+ * code, a social callback) and gating them one by one is how one gets missed.
119
+ *
120
+ * **The first account is always allowed.** An app nobody can sign into cannot be set up,
121
+ * and in `invite` mode there would be nobody to issue the invitation. Everybody after
122
+ * them goes through this; the first account also becomes the admin (see below).
123
+ */
124
+ async function assertSignUpAllowed(email: string): Promise<void> {
125
+ if (authConfig.signup === "open") return;
126
+
127
+ const [existing] = await getDb().select({ id: schema.user.id }).from(schema.user).limit(1);
128
+ if (!existing) return;
129
+
130
+ if (authConfig.signup === "closed") {
131
+ throw new APIError("FORBIDDEN", { message: "This app is not accepting new accounts." });
132
+ }
133
+
134
+ // `invite`: the token was put in a cookie when the invitation link was opened.
135
+ const token = (await cookies()).get(INVITE_COOKIE)?.value;
136
+ if (!(await invitationAllows(token, email))) {
137
+ throw new APIError("FORBIDDEN", { message: "You need an invitation to create an account here." });
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Spend the invitation this sign-up used, once the account exists.
143
+ *
144
+ * Separate from the check because the account does not exist yet when the check runs: an
145
+ * invitation has to survive a sign-up that fails validation, and only be used up by one
146
+ * that worked.
147
+ */
148
+ async function spendUsedInvitation(): Promise<void> {
149
+ if (authConfig.signup !== "invite") return;
150
+ const jar = await cookies();
151
+ const token = jar.get(INVITE_COOKIE)?.value;
152
+ if (!token) return;
153
+ await spendInvitation(token);
154
+ jar.delete(INVITE_COOKIE);
155
+ }
156
+
157
+ /**
158
+ * The first account to exist becomes the admin.
159
+ *
160
+ * Generated resources are admin-only, so without this a fresh app has a database, an API
161
+ * and a dashboard that nobody can reach — and the usual fix for that is to open
162
+ * everything up, which is how an admin panel ends up on the internet. The person running
163
+ * sign-up on an empty database is the person setting the app up.
164
+ *
165
+ * Done here rather than in `databaseHooks.user.create`, because the admin plugin already
166
+ * registers one of those (`{ role: defaultRole, ...user }`) and Better Auth does not merge
167
+ * the two — a second one is silently never called. This runs after the row exists and
168
+ * states the rule in SQL, which no plugin can quietly win.
169
+ *
170
+ * Everyone after them keeps the plugin's default role, and no role reaches an admin-only
171
+ * resource until you grant one with `flare user:role them@example.com admin`.
172
+ *
173
+ * Two people signing up in the same instant would both see two rows and neither would be
174
+ * promoted, leaving an app with no admin. It needs a brand-new app and millisecond timing,
175
+ * and `flare user:role you@example.com admin` fixes it, so this stays the simple rule
176
+ * rather than a transaction nobody can read.
177
+ */
178
+ let adminSettled = false;
179
+
180
+ async function promoteFirstUser() {
181
+ if (adminSettled) return;
182
+ const db = getDb();
183
+ // Two rows is all it takes to answer "is there exactly one?".
184
+ const rows = await db.select({ id: schema.user.id, role: schema.user.role }).from(schema.user).limit(2);
185
+ if (rows.length !== 1) {
186
+ // Nobody yet: the next sign-up asks again. More than one: the question is settled.
187
+ adminSettled = rows.length > 1;
188
+ return;
189
+ }
190
+ if (rows[0]!.role !== "admin") {
191
+ await db.update(schema.user).set({ role: "admin" }).where(eq(schema.user.id, rows[0]!.id));
192
+ }
193
+ adminSettled = true;
194
+ }
195
+
110
196
  export const auth = betterAuth({
111
197
  appName: "__APP_NAME__",
112
198
  secret: env.BETTER_AUTH_SECRET,
@@ -143,16 +229,40 @@ export const auth = betterAuth({
143
229
  // Let emails and other deferred work finish after the response is sent.
144
230
  backgroundTasks: { handler: waitUntil },
145
231
  },
232
+ databaseHooks: {
233
+ user: {
234
+ create: {
235
+ // The one place every sign-up passes through, whichever route it came in on.
236
+ before: async (user: { email: string }) => {
237
+ await assertSignUpAllowed(user.email);
238
+ return { data: user };
239
+ },
240
+ },
241
+ },
242
+ },
146
243
  hooks: {
147
244
  before: createAuthMiddleware(async (ctx) => {
148
245
  if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
149
246
  await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
150
247
  }),
248
+ // Any route can be the one that creates the first user — a password sign-up, a magic
249
+ // link, a social callback — so this asks after all of them rather than guessing which.
250
+ after: createAuthMiddleware(async () => {
251
+ await promoteFirstUser();
252
+ await spendUsedInvitation();
253
+ }),
151
254
  },
152
255
  plugins: [
153
- // Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
256
+ // Adds user.role (default "user") and admin APIs. The dashboard allows ADMIN_ROLES (lib/dashboard.ts).
154
257
  admin({ defaultRole: "user", adminRoles: ["admin"] }),
155
- magicLink({ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }), expiresIn: 5 * 60, disableSignUp: false }),
258
+ // disableSignUp follows the mode: a magic link to an address with no account would
259
+ // otherwise create one, which is a sign-up through a side door. In `invite` mode the
260
+ // link is still how an invited person signs *in* — they have an account by then.
261
+ magicLink({
262
+ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }),
263
+ expiresIn: 5 * 60,
264
+ disableSignUp: authConfig.signup !== "open",
265
+ }),
156
266
  emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
157
267
  twoFactor({
158
268
  issuer: "__APP_NAME__",
@@ -11,9 +11,14 @@ export interface DashboardLink {
11
11
  export const generatedDashboardLinks: DashboardLink[] = [];
12
12
  // generated:end
13
13
 
14
+ import { authConfig } from "./auth-config";
15
+
14
16
  /** Links shown under "Platform" in the dashboard sidebar. */
15
17
  export const dashboardLinks: DashboardLink[] = [
16
18
  { label: "Costs", href: "/dashboard/costs", icon: "wallet" },
19
+ // Only where invitations are the way in; the page explains itself for the other two
20
+ // modes, but there is no reason to send anybody to it.
21
+ ...(authConfig.signup === "invite" ? [{ label: "Invitations", href: "/dashboard/invitations", icon: "mail" }] : []),
17
22
  ...generatedDashboardLinks,
18
23
  { label: "API reference", href: "/api/reference", icon: "book" },
19
24
  ];