@flaredev/cli 0.10.0 → 0.11.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 CHANGED
@@ -141,12 +141,12 @@ function parseTheme(value) {
141
141
  // src/versions.ts
142
142
  var COMPATIBILITY_DATE = "2026-09-15";
143
143
  var APP_DEPENDENCIES = {
144
- "@better-auth/drizzle-adapter": "1.7.7",
145
- "@better-auth/passkey": "1.7.7",
144
+ "@better-auth/drizzle-adapter": "1.7.14",
145
+ "@better-auth/passkey": "1.7.14",
146
146
  // Server components import Slot from here rather than the "radix-ui" umbrella (see components/ui/button.tsx).
147
147
  "@radix-ui/react-slot": "1.3.3",
148
148
  "@vinext/cloudflare": "1.0.0-beta.8",
149
- "better-auth": "1.7.7",
149
+ "better-auth": "1.7.14",
150
150
  // Admin UI (shadcn/ui primitives in components/ui).
151
151
  "class-variance-authority": "^0.7.1",
152
152
  clsx: "^2.1.1",
@@ -172,7 +172,7 @@ var APP_DEPENDENCIES = {
172
172
  "react-server-dom-webpack": "19.3.0",
173
173
  vinext: "1.0.0-beta.10"
174
174
  };
175
- var BETTER_AUTH_VERSION = "1.7.7";
175
+ var BETTER_AUTH_VERSION = "1.7.14";
176
176
  var API_KEY_PACKAGE = "@better-auth/api-key";
177
177
  var APP_DEV_DEPENDENCIES = {
178
178
  "@cloudflare/vite-plugin": "^1.54.10",
@@ -198,7 +198,7 @@ var NEXT_DEPENDENCIES = {
198
198
  "@prisma/adapter-pg": "7.10.0",
199
199
  pg: "^8.16.3",
200
200
  "@upstash/redis": "^1.36.0",
201
- "@better-auth/prisma-adapter": "1.7.7",
201
+ "@better-auth/prisma-adapter": "1.7.14",
202
202
  // Signs S3 requests in a few kilobytes, where the AWS SDK is megabytes.
203
203
  aws4fetch: "^1.0.20",
204
204
  dotenv: "^17.2.3"
@@ -233,7 +233,7 @@ var CLOUDFLARE_ONLY = [
233
233
  // src/stack.ts
234
234
  import { existsSync, readFileSync, writeFileSync } from "fs";
235
235
  import { join } from "path";
236
- var STACKS = ["cloudflare", "next"];
236
+ var STACKS = ["next", "cloudflare"];
237
237
  var isStack = (value) => STACKS.includes(value);
238
238
  var STACK_LABELS = {
239
239
  cloudflare: "Cloudflare Workers, D1 and Drizzle \u2014 cheapest to run, deploys everywhere",
@@ -357,7 +357,7 @@ function createApp(target, options2 = {}) {
357
357
  if (options2.stack !== void 0 && !isStack(options2.stack)) {
358
358
  throw new Error(`Unknown stack "${options2.stack}". Use ${STACKS.join(" or ")}.`);
359
359
  }
360
- const stack = options2.stack ?? "cloudflare";
360
+ const stack = options2.stack ?? "next";
361
361
  const authProviders = parseAuthProviders(options2.authProviders);
362
362
  const authMethods = parseAuthMethods(options2.auth);
363
363
  const theme = parseTheme(options2.theme);
@@ -376,9 +376,8 @@ function createApp(target, options2 = {}) {
376
376
  writeFileSync3(join4(dir, "lib", "auth-config.ts"), renderAuthConfig(authMethods, authProviders));
377
377
  appendFileSync(join4(dir, stack === "next" ? ".env.example" : ".dev.vars.example"), devVarsExampleEntries(authProviders));
378
378
  const shared = { react: APP_DEPENDENCIES.react, "react-dom": APP_DEPENDENCIES["react-dom"] };
379
- const uiOnly = Object.fromEntries(
380
- Object.entries(APP_DEPENDENCIES).filter(([key]) => !["vinext", "@vinext/cloudflare", "drizzle-orm", "react-server-dom-webpack"].includes(key))
381
- );
379
+ const cloudflareOnlyPackages = ["vinext", "@vinext/cloudflare", "drizzle-orm", "react-server-dom-webpack", "@better-auth/drizzle-adapter"];
380
+ const uiOnly = Object.fromEntries(Object.entries(APP_DEPENDENCIES).filter(([key]) => !cloudflareOnlyPackages.includes(key)));
382
381
  writeJson(join4(dir, "package.json"), {
383
382
  name,
384
383
  version: "0.1.0",
@@ -417,7 +416,8 @@ function createApp(target, options2 = {}) {
417
416
  `BETTER_AUTH_SECRET=${randomBytes(32).toString("base64")}
418
417
  RESEND_API_KEY=
419
418
  MAIL_FROM=
420
- ${devVarsEntries(authProviders)}`
419
+ ${devVarsEntries(authProviders)}` + (stack === "next" ? `
420
+ ${LOCAL_SERVICES}` : "")
421
421
  );
422
422
  if (packageManager === "pnpm" && !inWorkspace) {
423
423
  writeFileSync3(join4(dir, "pnpm-workspace.yaml"), `allowBuilds:
@@ -425,16 +425,28 @@ ${ALLOW_BUILDS}
425
425
  `);
426
426
  }
427
427
  const pinned = flarePackageSpec("core", dir, packageManager).startsWith("^");
428
- if (pinned) copyLockfile(packageManager, dir);
428
+ if (pinned) copyLockfile(packageManager, dir, stack);
429
429
  const log2 = options2.log ?? ((message) => console.log(message));
430
430
  log2(`${pc.green("\u2714")} Wrote ${files} files to ${relative(process.cwd(), dir) || "."}`);
431
431
  if (prefersPnpm) log2(pc.dim(`Installing with pnpm \u2014 far quicker for an app this size. Use ${pc.bold("--pm npm")} to change that.`));
432
432
  return { dir, name, packageManager, inWorkspace };
433
433
  }
434
- function copyLockfile(packageManager, dir) {
434
+ var LOCAL_SERVICES = [
435
+ "# Local services (docker-compose.yml). Swap these for Neon, R2 and Upstash to deploy.",
436
+ "DATABASE_URL=postgresql://flare:flare@127.0.0.1:5432/app",
437
+ "BETTER_AUTH_URL=http://localhost:3000",
438
+ "R2_ENDPOINT=http://127.0.0.1:9000",
439
+ "R2_BUCKET=app-uploads",
440
+ "R2_ACCESS_KEY_ID=flare",
441
+ "R2_SECRET_ACCESS_KEY=flare-secret-123",
442
+ "UPSTASH_REDIS_REST_URL=http://127.0.0.1:8079",
443
+ "UPSTASH_REDIS_REST_TOKEN=flare-local-token",
444
+ ""
445
+ ].join("\n");
446
+ function copyLockfile(packageManager, dir, stack) {
435
447
  const file = packageManager === "pnpm" ? "pnpm-lock.yaml" : packageManager === "yarn" ? "yarn.lock" : "package-lock.json";
436
448
  if (!file) return false;
437
- const source = join4(templatesDir, "locks", file);
449
+ const source = join4(templatesDir, "locks", stack, file);
438
450
  if (!existsSync4(source)) return false;
439
451
  copyFileSync(source, join4(dir, file));
440
452
  return true;
@@ -1692,11 +1704,37 @@ function list(raw, what, spec) {
1692
1704
  return items;
1693
1705
  }
1694
1706
  var RESOURCE_NAME = /^[A-Z][A-Za-z0-9]*$/;
1707
+ var CONDITION = /^(.*?)\s+if\s+(.+)$/s;
1708
+ var COMPARISON = /^([A-Za-z][A-Za-z0-9_]*)\s*(==|!=|\bin\b)\s*(.+)$/;
1709
+ function parseCondition(clause, spec) {
1710
+ const match = COMPARISON.exec(clause.trim());
1711
+ if (!match) {
1712
+ throw new FieldGrammarError(`"${spec}": "${clause.trim()}" isn't a condition. Write it like "if kind == recurring".`);
1713
+ }
1714
+ const [, dependsOn, operator, rawValue] = match;
1715
+ const op = operator === "==" ? "eq" : operator === "!=" ? "ne" : "in";
1716
+ const text = rawValue.trim();
1717
+ if (op === "in") {
1718
+ const inner = /^\((.*)\)$/.exec(text);
1719
+ if (!inner) throw new FieldGrammarError(`"${spec}": "in" takes a list in brackets, e.g. "if kind in (recurring, retainer)".`);
1720
+ return { field: dependsOn, op, value: list(inner[1], "condition value", spec) };
1721
+ }
1722
+ if (!text || /\s/.test(text)) {
1723
+ throw new FieldGrammarError(`"${spec}": "${text}" isn't a single value. Write it like "if kind == recurring".`);
1724
+ }
1725
+ return { field: dependsOn, op, value: text };
1726
+ }
1695
1727
  function parseField(spec) {
1696
1728
  const colon = spec.indexOf(":");
1697
1729
  if (colon <= 0) throw new FieldGrammarError(`"${spec}": expected name:type, e.g. "title:string".`);
1698
1730
  const rawName = spec.slice(0, colon).trim();
1699
1731
  let type = spec.slice(colon + 1).trim();
1732
+ let showWhen;
1733
+ const conditioned = CONDITION.exec(type);
1734
+ if (conditioned) {
1735
+ type = conditioned[1].trim();
1736
+ showWhen = parseCondition(conditioned[2], spec);
1737
+ }
1700
1738
  let required = true;
1701
1739
  let unique = false;
1702
1740
  while (/[?!]$/.test(type)) {
@@ -1709,6 +1747,7 @@ function parseField(spec) {
1709
1747
  }
1710
1748
  let key = camelCase(rawName);
1711
1749
  const field = { key, kind: "string", required, unique };
1750
+ if (showWhen) field.showWhen = showWhen;
1712
1751
  let match;
1713
1752
  if (SIMPLE_KINDS.includes(type)) {
1714
1753
  field.kind = type;
@@ -1786,6 +1825,40 @@ function parseFields(input) {
1786
1825
  if (seen.has(field.key)) throw new FieldGrammarError(`Field "${field.key}" is declared more than once.`);
1787
1826
  seen.add(field.key);
1788
1827
  }
1828
+ const byKey = new Map(fields.map((field) => [field.key, field]));
1829
+ for (const field of fields) {
1830
+ const condition = field.showWhen;
1831
+ if (!condition) continue;
1832
+ if (condition.field === field.key) {
1833
+ throw new FieldGrammarError(`Field "${field.key}" cannot depend on itself.`);
1834
+ }
1835
+ const target = byKey.get(condition.field);
1836
+ if (!target) {
1837
+ const names = fields.map((other) => other.key).filter((name) => name !== field.key);
1838
+ throw new FieldGrammarError(
1839
+ `Field "${field.key}" depends on "${condition.field}", which isn't a field here.${suggest(condition.field, names)} Fields: ${names.join(", ")}.`
1840
+ );
1841
+ }
1842
+ if (target.showWhen) {
1843
+ throw new FieldGrammarError(`Field "${field.key}" depends on "${target.key}", which is itself conditional. Conditions only go one level deep.`);
1844
+ }
1845
+ const values = Array.isArray(condition.value) ? condition.value : [condition.value];
1846
+ if (target.options) {
1847
+ for (const value of values) {
1848
+ if (!target.options.includes(value)) {
1849
+ throw new FieldGrammarError(
1850
+ `Field "${field.key}" is shown when ${condition.field} is "${value}", which isn't one of its options.${suggest(value, target.options)} Options: ${target.options.join(", ")}.`
1851
+ );
1852
+ }
1853
+ }
1854
+ } else if (target.kind === "boolean") {
1855
+ for (const value of values) {
1856
+ if (value !== "true" && value !== "false") {
1857
+ throw new FieldGrammarError(`Field "${field.key}" depends on the boolean "${condition.field}", so compare it with true or false, not "${value}".`);
1858
+ }
1859
+ }
1860
+ }
1861
+ }
1789
1862
  return fields;
1790
1863
  }
1791
1864
 
@@ -1807,6 +1880,7 @@ function options(field) {
1807
1880
  if (field.widget) opts.widget = field.widget;
1808
1881
  if (field.kind === "belongsTo" && !field.required) opts.onDelete = "set null";
1809
1882
  if (field.maxBytes !== void 0) opts.maxBytes = field.maxBytes;
1883
+ if (field.showWhen) opts.showWhen = field.showWhen;
1810
1884
  return opts;
1811
1885
  }
1812
1886
  function toField(field) {
@@ -1830,7 +1904,13 @@ function toField(field) {
1830
1904
  return builders[field.kind](opts);
1831
1905
  }
1832
1906
  }
1833
- var literal = (value) => JSON.stringify(value);
1907
+ var literal = (value) => {
1908
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1909
+ const entries = Object.entries(value);
1910
+ return `{ ${entries.map(([key, inner]) => `${/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key)}: ${literal(inner)}`).join(", ")} }`;
1911
+ }
1912
+ return JSON.stringify(value);
1913
+ };
1834
1914
  function renderOptions(opts) {
1835
1915
  const entries = Object.entries(opts);
1836
1916
  return entries.length ? `{ ${entries.map(([k, v]) => `${k}: ${literal(v)}`).join(", ")} }` : "";
@@ -2731,7 +2811,7 @@ function renderPrismaModel(entry, all) {
2731
2811
  if (def.kind === "belongsTo") {
2732
2812
  const target = byName.get(def.target);
2733
2813
  if (!target) throw new Error(`${resource.name}.${key} belongs to "${def.target}", but there is no ${def.target} resource. Generate it first.`);
2734
- const optional2 = def.required ? "" : "?";
2814
+ const optional2 = def.required && !def.showWhen ? "" : "?";
2735
2815
  const relationField = key.replace(/Id$/, "");
2736
2816
  const named = backReferences(target, all).find((back) => back.from === resource.name && back.relationName?.endsWith(`_${key}`));
2737
2817
  const relationArgs = [
@@ -2745,7 +2825,7 @@ function renderPrismaModel(entry, all) {
2745
2825
  indexes.push(` @@index([${key}])`);
2746
2826
  continue;
2747
2827
  }
2748
- const optional = def.required || def.kind === "multiselect" || def.kind === "tags" ? "" : "?";
2828
+ const optional = def.required && !def.showWhen || def.kind === "multiselect" || def.kind === "tags" ? "" : "?";
2749
2829
  lines.push(` ${key} ${prismaType(resource, key, def)}${optional} ${attributes(key, def).join(" ")}`.trimEnd());
2750
2830
  }
2751
2831
  for (const back of backReferences(resource, all)) {
@@ -2820,7 +2900,7 @@ function columnExpression(key, def, resources, selfName) {
2820
2900
  default:
2821
2901
  expr = `text(${col})`;
2822
2902
  }
2823
- if (def.required) expr += ".notNull()";
2903
+ if (def.required && !def.showWhen) expr += ".notNull()";
2824
2904
  if ("unique" in def && def.unique) expr += ".unique()";
2825
2905
  if ("default" in def && def.default !== void 0) expr += `.default(${q2(def.default)})`;
2826
2906
  return expr;
@@ -4137,7 +4217,7 @@ async function askCreateQuestions(given, appName) {
4137
4217
  answers.stack = stopIfCancelled(
4138
4218
  await prompts2.select({
4139
4219
  message: "Where will it run?",
4140
- initialValue: "cloudflare",
4220
+ initialValue: "next",
4141
4221
  options: STACKS.map((value) => ({ value, label: value === "next" ? "Next.js" : "Cloudflare", hint: STACK_LABELS[value] }))
4142
4222
  })
4143
4223
  );
@@ -4231,13 +4311,22 @@ async function genResource(rawName, options2) {
4231
4311
  }
4232
4312
  }
4233
4313
  }
4314
+ const policyRelative = policyPath(name);
4315
+ const policyAbsolute = join17(appRoot, policyRelative);
4316
+ const wrotePolicy = !exists && !existsSync18(policyAbsolute);
4317
+ if (wrotePolicy) {
4318
+ mkdirSync8(dirname9(policyAbsolute), { recursive: true });
4319
+ writeFileSync11(policyAbsolute, renderPolicy(name, { read: ["admin"], create: ["admin"], update: ["admin"], delete: ["admin"] }));
4320
+ log2(`${pc11.green("create".padEnd(9))} ${policyRelative}`);
4321
+ }
4234
4322
  let written;
4235
4323
  let table;
4236
4324
  try {
4237
4325
  const all = await loadResources(appRoot);
4238
4326
  table = all.find(({ resource }) => resource.name === name).resource.table;
4239
4327
  ensureSupportFiles(appRoot, log2);
4240
- written = applyPlan(appRoot, planFiles(all, void 0, readStack(appRoot)), { force: options2.force });
4328
+ const policies = wrotePolicy ? await loadPolicies(appRoot) : void 0;
4329
+ written = applyPlan(appRoot, planFiles(all, policies, readStack(appRoot)), { force: options2.force });
4241
4330
  logResults(written, log2);
4242
4331
  } catch (error) {
4243
4332
  const hint = exists ? "" : `
@@ -4265,7 +4354,8 @@ Next: ${pc11.bold("flare migrate")} to apply it locally.`);
4265
4354
  }
4266
4355
  }
4267
4356
  const descriptor = { path: relativePath, status: exists ? "outdated" : "missing", outcome: exists ? "update" : "create" };
4268
- return { name, mode: exists ? "update" : "create", written: [descriptor, ...written], migrations };
4357
+ const policy = wrotePolicy ? [{ path: policyRelative, status: "missing", outcome: "create" }] : [];
4358
+ return { name, mode: exists ? "update" : "create", written: [descriptor, ...policy, ...written], migrations };
4269
4359
  }
4270
4360
 
4271
4361
  // src/commands/gen-endpoint.ts
@@ -5421,6 +5511,13 @@ Run it with ${pc19.bold(`flare seed ${name}`)}.`);
5421
5511
  return relative3;
5422
5512
  }
5423
5513
  async function runPrismaSeeds(appRoot, files, log2) {
5514
+ if (!existsSync25(join25(appRoot, "lib", "generated", "prisma"))) {
5515
+ log2(pc19.dim("Generating the Prisma client first (migrate dev no longer does)\u2026"));
5516
+ const prisma = resolveBin(appRoot, "prisma", "prisma");
5517
+ if (await runNode([prisma, "generate"], appRoot) !== 0) {
5518
+ throw new Error("`prisma generate` failed, so there is no client for the seeds to use.");
5519
+ }
5520
+ }
5424
5521
  const runner = join25(appRoot, ".flare-seed-runner.mts");
5425
5522
  const script = [
5426
5523
  `import "dotenv/config";`,
@@ -6228,7 +6325,7 @@ function createCli() {
6228
6325
  const stack = currentStack();
6229
6326
  const perStack = (cloudflare, next, either) => stack === "next" ? next : stack === "cloudflare" ? cloudflare : either;
6230
6327
  const cloudflareOnly = (text) => stack === void 0 ? `${text} (Cloudflare only)` : text;
6231
- cli.command("create <dir>", "Scaffold a new Flare app").option("--pm <manager>", "Package manager: pnpm, npm, yarn, or bun (default: detected)").option("--stack <name>", "Where it runs: cloudflare (default) or next").option("--theme <name>", "Look of the app: default, coral, amber, sky, mono or emerald").option("--auth <list>", "Sign-in methods: magic-link, email-otp, passkeys, 2fa-app, 2fa-email, all or none (default: all)").option("--auth-providers <list>", "Social sign-in, comma-separated: google, github, apple, microsoft").option("-y, --yes", "Don't ask: use the flags given and the defaults for the rest").option("--skip-install", "Write files without installing dependencies").example("flare create shop").example("flare create shop --stack next").example("flare create shop --theme mono --auth passkeys,2fa-app --auth-providers google,github --yes").action(
6328
+ cli.command("create <dir>", "Scaffold a new Flare app").option("--pm <manager>", "Package manager: pnpm, npm, yarn, or bun (default: detected)").option("--stack <name>", "Where it runs: next (default) or cloudflare").option("--theme <name>", "Look of the app: default, coral, amber, sky, mono or emerald").option("--auth <list>", "Sign-in methods: magic-link, email-otp, passkeys, 2fa-app, 2fa-email, all or none (default: all)").option("--auth-providers <list>", "Social sign-in, comma-separated: google, github, apple, microsoft").option("-y, --yes", "Don't ask: use the flags given and the defaults for the rest").option("--skip-install", "Write files without installing dependencies").example("flare create shop").example("flare create shop --stack next").example("flare create shop --theme mono --auth passkeys,2fa-app --auth-providers google,github --yes").action(
6232
6329
  async (dir, options2) => {
6233
6330
  const wanted = !options2.skipInstall;
6234
6331
  let answers = { stack: options2.stack, theme: options2.theme, auth: options2.auth, authProviders: options2.authProviders };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
5
5
  "license": "MIT",
6
6
  "author": "Muke Johnbaptist",
@@ -41,7 +41,7 @@
41
41
  "jiti": "^2.7.0",
42
42
  "jsonc-parser": "^3.3.1",
43
43
  "picocolors": "^1.1.1",
44
- "@flaredev/core": "0.10.0"
44
+ "@flaredev/core": "0.11.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22"
@@ -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
 
@@ -9,6 +9,7 @@ import { emailOTP } from "better-auth/plugins/email-otp";
9
9
  import { magicLink } from "better-auth/plugins/magic-link";
10
10
  import { twoFactor } from "better-auth/plugins/two-factor";
11
11
  import { hashPassword, verifyPassword } from "@flaredev/core";
12
+ import { eq } from "drizzle-orm";
12
13
  import { getDb, schema } from "@/db";
13
14
  import { authConfig, type SocialProvider } from "./auth-config";
14
15
  import { checkPassword, isBreachedPassword, PASSWORD_MAX_LENGTH } from "./password-rules";
@@ -107,6 +108,45 @@ async function guardPassword(path: string, body: Record<string, unknown> | undef
107
108
  }
108
109
  }
109
110
 
111
+ /**
112
+ * The first account to exist becomes the admin.
113
+ *
114
+ * Generated resources are admin-only, so without this a fresh app has a database, an API
115
+ * and a dashboard that nobody can reach — and the usual fix for that is to open
116
+ * everything up, which is how an admin panel ends up on the internet. The person running
117
+ * sign-up on an empty database is the person setting the app up.
118
+ *
119
+ * Done here rather than in `databaseHooks.user.create`, because the admin plugin already
120
+ * registers one of those (`{ role: defaultRole, ...user }`) and Better Auth does not merge
121
+ * the two — a second one is silently never called. This runs after the row exists and
122
+ * states the rule in SQL, which no plugin can quietly win.
123
+ *
124
+ * Everyone after them keeps the plugin's default role, and no role reaches an admin-only
125
+ * resource until you grant one with `flare user:role them@example.com admin`.
126
+ *
127
+ * Two people signing up in the same instant would both see two rows and neither would be
128
+ * promoted, leaving an app with no admin. It needs a brand-new app and millisecond timing,
129
+ * and `flare user:role you@example.com admin` fixes it, so this stays the simple rule
130
+ * rather than a transaction nobody can read.
131
+ */
132
+ let adminSettled = false;
133
+
134
+ async function promoteFirstUser() {
135
+ if (adminSettled) return;
136
+ const db = getDb();
137
+ // Two rows is all it takes to answer "is there exactly one?".
138
+ const rows = await db.select({ id: schema.user.id, role: schema.user.role }).from(schema.user).limit(2);
139
+ if (rows.length !== 1) {
140
+ // Nobody yet: the next sign-up asks again. More than one: the question is settled.
141
+ adminSettled = rows.length > 1;
142
+ return;
143
+ }
144
+ if (rows[0]!.role !== "admin") {
145
+ await db.update(schema.user).set({ role: "admin" }).where(eq(schema.user.id, rows[0]!.id));
146
+ }
147
+ adminSettled = true;
148
+ }
149
+
110
150
  export const auth = betterAuth({
111
151
  appName: "__APP_NAME__",
112
152
  secret: env.BETTER_AUTH_SECRET,
@@ -148,6 +188,11 @@ export const auth = betterAuth({
148
188
  if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
149
189
  await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
150
190
  }),
191
+ // Any route can be the one that creates the first user — a password sign-up, a magic
192
+ // link, a social callback — so this asks after all of them rather than guessing which.
193
+ after: createAuthMiddleware(async () => {
194
+ await promoteFirstUser();
195
+ }),
151
196
  },
152
197
  plugins: [
153
198
  // Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
@@ -108,9 +108,14 @@ export function drizzleRows(table: SQLiteTable, getDb: () => AnyDatabase): Resou
108
108
  return (record as Row | undefined) ?? null;
109
109
  },
110
110
 
111
- async titles(ids, titleField) {
111
+ async titles(ids, titleField, owner) {
112
112
  const titleColumn = columns[titleField] ?? idColumn;
113
- const rows = await getDb().select({ id: idColumn, title: titleColumn }).from(table).where(inArray(idColumn, ids));
113
+ const ownerColumn = owner ? columns[owner.field] : undefined;
114
+ // An owner naming a column this table does not have would widen the query rather
115
+ // than narrow it, so it answers nothing instead.
116
+ if (owner && !ownerColumn) return [];
117
+ const where = ownerColumn ? and(inArray(idColumn, ids), eq(ownerColumn, owner!.value)) : inArray(idColumn, ids);
118
+ const rows = await getDb().select({ id: idColumn, title: titleColumn }).from(table).where(where);
114
119
  return rows as { id: string; title: unknown }[];
115
120
  },
116
121
 
@@ -78,7 +78,11 @@ export interface ResourceRows {
78
78
  /** How many rows match, giving up at `limit` so a huge table is never fully counted. */
79
79
  countUpTo(query: Pick<RowsQuery, "search" | "filters" | "deleted">, limit: number): Promise<number>;
80
80
  byId(id: string): Promise<Row | null>;
81
- titles(ids: string[], titleField: string): Promise<{ id: string; title: unknown }[]>;
81
+ /**
82
+ * Title values for a set of ids. `owner`, when given, restricts the rows considered —
83
+ * a relation picker shown to a confined user must not name records they cannot see.
84
+ */
85
+ titles(ids: string[], titleField: string, owner?: { field: string; value: string }): Promise<{ id: string; title: unknown }[]>;
82
86
  insert(values: Row): Promise<Row>;
83
87
  /** The saved row, or null when there was nothing with that id. */
84
88
  update(id: string, values: Row): Promise<Row | null>;
@@ -8,7 +8,7 @@
8
8
  // `flare diff` shows how your copy differs from the version Flare ships, and
9
9
  // `flare update` applies the parts you choose. Neither runs unless you ask.
10
10
 
11
- import { columnName, fromMinorUnits, ownershipFilter, storedFields, toMinorUnits, type Policy, type Resource } from "@flaredev/core";
11
+ import { columnName, conditionHolds, fromMinorUnits, ownershipFilter, storedFields, toMinorUnits, type Policy, type Resource } from "@flaredev/core";
12
12
  import { createValidators } from "@flaredev/core";
13
13
  import { encodeCursor, isSearchable, parseListQuery, type QueryIssue } from "./query";
14
14
  import type { ResourceRows, Row } from "./rows";
@@ -212,6 +212,34 @@ export function createResourceStore(options: ResourceStoreOptions) {
212
212
  (def.kind === "int" || def.kind === "float") && def.format === "money" ? [{ key, currency: def.currency }] : [],
213
213
  );
214
214
 
215
+ /**
216
+ * Fields that only apply when another field says so, e.g. a billing cycle that exists
217
+ * on a recurring project and not on a one-off one.
218
+ *
219
+ * The validator decides whether one is *required*; this decides whether it is *kept*.
220
+ * Without it, changing a project from recurring to one-off leaves last month's billing
221
+ * cycle sitting in the row — the form stopped showing the input, so nothing ever sent a
222
+ * value to overwrite it, and the record quietly disagrees with itself.
223
+ */
224
+ const conditionalFields = fields.flatMap(([key, def]) => (def.showWhen ? [{ key, condition: def.showWhen }] : []));
225
+
226
+ /**
227
+ * Null the fields that no longer apply.
228
+ *
229
+ * `record` is what the condition is judged against: the whole record as it will be once
230
+ * this write lands, which on a PATCH means the stored row with the patch laid over it.
231
+ * Judging the patch alone would read a field it never mentioned as absent and clear a
232
+ * value that was meant to stay.
233
+ */
234
+ function clearInapplicable(values: Row, record: Row): Row {
235
+ if (conditionalFields.length === 0) return values;
236
+ const out = { ...values };
237
+ for (const { key, condition } of conditionalFields) {
238
+ if (!conditionHolds(condition, record)) out[key] = null;
239
+ }
240
+ return out;
241
+ }
242
+
215
243
  /** Amounts as the database stores them: 19.99 -> 1999. */
216
244
  function toStored(values: Row): Row {
217
245
  if (moneyFields.length === 0) return values;
@@ -405,16 +433,19 @@ export function createResourceStore(options: ResourceStoreOptions) {
405
433
  /**
406
434
  * Title-field values for a set of ids (for showing relations). Missing ids are omitted.
407
435
  *
408
- * Not confined by ownership, and `rows.titles` has no way to be: it returns an id and
409
- * a title, not the owner. It is called with ids this resource's rows already point at,
410
- * so in practice it shows the names of records you can already see — but if you hand a
411
- * confined user a relation picker over a confined resource, the titles are not the
412
- * place that restriction is enforced. Filter the ids before calling it.
436
+ * Confined by ownership, like every other read: a relation picker shown to a user who
437
+ * only sees their own rows must not name anybody else's. An id they may not see is
438
+ * simply absent from the result, which the caller already handles — missing ids are
439
+ * omitted either way, so a title they are not entitled to looks the same as one that
440
+ * was deleted.
413
441
  */
414
442
  async titles(ids: string[]): Promise<Record<string, string>> {
415
443
  const unique = [...new Set(ids.filter(Boolean))];
416
444
  if (unique.length === 0) return {};
417
- const found = await rows.titles(unique, resource.titleField);
445
+ const limit = await confine();
446
+ // Not signed in where sign-in is required: no titles rather than everyone's.
447
+ if (isFailure(limit)) return {};
448
+ const found = await rows.titles(unique, resource.titleField, limit ?? undefined);
418
449
  return Object.fromEntries(found.map((row) => [row.id, row.title == null ? row.id : String(row.title)]));
419
450
  },
420
451
 
@@ -436,7 +467,8 @@ export function createResourceStore(options: ResourceStoreOptions) {
436
467
  if (!result.success) return invalid(result.error.issues);
437
468
  const input = result.data as Record<string, unknown>;
438
469
  const now = new Date();
439
- const record = await rows.insert(toStored({ ...input, id: crypto.randomUUID(), createdAt: now, updatedAt: now }));
470
+ const fresh = { ...input, id: crypto.randomUUID(), createdAt: now, updatedAt: now };
471
+ const record = await rows.insert(toStored(clearInapplicable(fresh, fresh)));
440
472
  const saved = withComputed(record);
441
473
  await hooks.afterCreate?.(saved, context);
442
474
  return { ok: true, data: saved };
@@ -452,7 +484,10 @@ export function createResourceStore(options: ResourceStoreOptions) {
452
484
 
453
485
  // A confined update has to read the row first: `rows.update(id, …)` takes an id
454
486
  // and nothing else, so this is where "is it yours" is answered.
455
- const current = limit || hooks.beforeUpdate || hooks.afterUpdate ? await rows.byId(id) : null;
487
+ // Conditional fields join this list: deciding whether one still applies needs the
488
+ // whole record, and a PATCH on its own may not mention the field it depends on.
489
+ const needsCurrent = limit || hooks.beforeUpdate || hooks.afterUpdate || conditionalFields.length > 0;
490
+ const current = needsCurrent ? await rows.byId(id) : null;
456
491
  if (limit) {
457
492
  if (!current) return notFound();
458
493
  if (!owns(current, limit)) return notFound();
@@ -476,7 +511,8 @@ export function createResourceStore(options: ResourceStoreOptions) {
476
511
  const result = validators.update.safeParse(supplied);
477
512
  if (!result.success) return invalid(result.error.issues);
478
513
  const input = result.data as Record<string, unknown>;
479
- const record = await rows.update(id, toStored({ ...input, updatedAt: new Date() }));
514
+ const merged = { ...((current ?? {}) as Row), ...input };
515
+ const record = await rows.update(id, toStored(clearInapplicable({ ...input, updatedAt: new Date() }, merged)));
480
516
  if (!record) return notFound();
481
517
  const saved = withComputed(record);
482
518
  await hooks.afterUpdate?.(saved, { ...context, previous: current });
@@ -505,7 +541,7 @@ export function createResourceStore(options: ResourceStoreOptions) {
505
541
  // one of them, and a replace would quietly orphan the row it just saved.
506
542
  if (limit) values[limit.field] = limit.value;
507
543
 
508
- const record = await rows.update(id, toStored(values));
544
+ const record = await rows.update(id, toStored(clearInapplicable(values, values)));
509
545
  return record ? { ok: true, data: withComputed(record) } : notFound();
510
546
  });
511
547
  },