@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,142 @@
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
+ * Invitations, for an app whose sign-up is `invite` (see lib/auth-config.ts).
12
+ *
13
+ * **They live in the `verification` table**, which Better Auth already has and already
14
+ * uses for exactly this shape of thing: a single-use token with an expiry. Reusing it
15
+ * means invitations need no table of their own and so no migration — an app switching to
16
+ * `invite` changes one line in auth-config.ts and nothing else.
17
+ *
18
+ * Rows are keyed `invite:<hash>`. Only the hash is stored, never the token itself, for the
19
+ * same reason a password is not: a leaked database should not be a pile of working
20
+ * invitations.
21
+ *
22
+ * The flow:
23
+ *
24
+ * 1. An admin creates one. `createInvitation` returns the token once — it is not
25
+ * recoverable afterwards, which is why the link is shown on screen to copy.
26
+ * 2. The invited person opens the link. `/invite/[token]` puts the token in a
27
+ * short-lived cookie and sends them to the sign-up page.
28
+ * 3. They sign up. `lib/auth.ts` reads the cookie, checks the invitation, and lets the
29
+ * account be created — then spends it, so the link cannot be used twice.
30
+ */
31
+ import { and, eq, like, lt } from "drizzle-orm";
32
+ import { getDb } from "@/db";
33
+ import { verification } from "@/db/auth-schema";
34
+
35
+ /** How long an invitation is good for, unless you say otherwise. */
36
+ export const DEFAULT_INVITE_DAYS = 7;
37
+
38
+ /** The cookie an opened invitation link leaves behind, for the sign-up that follows. */
39
+ export const INVITE_COOKIE = "flare_invite";
40
+
41
+ /** How long that cookie lives: long enough to fill in a form, not long enough to forget. */
42
+ export const INVITE_COOKIE_MAX_AGE = 60 * 60;
43
+
44
+ const KEY_PREFIX = "invite:";
45
+
46
+ /**
47
+ * WebCrypto rather than node:crypto, so the same file runs on Workers and on Node
48
+ * without a compatibility flag deciding whether it does.
49
+ */
50
+ const randomToken = () => {
51
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
52
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
53
+ };
54
+
55
+ /** Only the hash is stored, so a copy of the table is not a pile of working invitations. */
56
+ async function hash(token: string): Promise<string> {
57
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
58
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
59
+ }
60
+
61
+ const key = async (token: string) => `${KEY_PREFIX}${await hash(token)}`;
62
+
63
+ /** What an invitation is for: one address, or anybody who has the link. */
64
+ export type InviteFor = { email: string } | { anyone: true };
65
+
66
+ /** An invitation. The token is returned once and never stored. */
67
+ export interface CreatedInvitation {
68
+ token: string;
69
+ expiresAt: Date;
70
+ /** Who it is for, or null when anybody with the link may use it. */
71
+ email: string | null;
72
+ }
73
+
74
+ /** Create one. The returned token is the only copy. */
75
+ export async function createInvitation(options: { for: InviteFor; days?: number }): Promise<CreatedInvitation> {
76
+ const token = randomToken();
77
+ const email = "email" in options.for ? options.for.email.trim().toLowerCase() : null;
78
+ const expiresAt = new Date(Date.now() + (options.days ?? DEFAULT_INVITE_DAYS) * 86_400_000);
79
+ await getDb()
80
+ .insert(verification)
81
+ .values({
82
+ id: crypto.randomUUID(),
83
+ identifier: await key(token),
84
+ // `*` rather than an empty string, so "for anybody" is a value and not a mistake.
85
+ value: email ?? "*",
86
+ expiresAt,
87
+ createdAt: new Date(),
88
+ updatedAt: new Date(),
89
+ });
90
+ return { token, expiresAt, email };
91
+ }
92
+
93
+ /** The link to send somebody, given the app's own origin. */
94
+ export const invitationLink = (baseUrl: string, token: string) => `${baseUrl.replace(/\/$/, "")}/invite/${token}`;
95
+
96
+ /**
97
+ * Whether this token is a live invitation for this email address.
98
+ *
99
+ * Checked, not spent: the account does not exist yet when this is asked, and an
100
+ * invitation should survive a sign-up that fails validation.
101
+ */
102
+ export async function invitationAllows(token: string | undefined, email: string): Promise<boolean> {
103
+ if (!token) return false;
104
+ const rows = await getDb().select().from(verification).where(eq(verification.identifier, await key(token))).limit(1);
105
+ const row = rows[0];
106
+ if (!row) return false;
107
+ if (row.expiresAt.getTime() <= Date.now()) return false;
108
+ // An invitation addressed to somebody is for them alone; `*` is for whoever has the link.
109
+ return row.value === "*" || row.value === email.trim().toLowerCase();
110
+ }
111
+
112
+ /** Spend it, so the link cannot be used again. Called once the account exists. */
113
+ export async function spendInvitation(token: string | undefined): Promise<void> {
114
+ if (!token) return;
115
+ await getDb().delete(verification).where(eq(verification.identifier, await key(token)));
116
+ }
117
+
118
+ /** Invitations an admin can see: live ones, newest first. Never the tokens. */
119
+ export async function listInvitations(): Promise<{ id: string; email: string | null; expiresAt: Date; createdAt: Date }[]> {
120
+ const rows = await getDb().select().from(verification).where(like(verification.identifier, `${KEY_PREFIX}%`));
121
+ return rows
122
+ .filter((row) => row.expiresAt.getTime() > Date.now())
123
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
124
+ .map((row) => ({ id: row.id, email: row.value === "*" ? null : row.value, expiresAt: row.expiresAt, createdAt: row.createdAt }));
125
+ }
126
+
127
+ /** Revoke one by its row id, so a link sent to the wrong address stops working. */
128
+ export async function revokeInvitation(id: string): Promise<void> {
129
+ await getDb().delete(verification).where(eq(verification.id, id));
130
+ }
131
+
132
+ /**
133
+ * Drop invitations that have expired.
134
+ *
135
+ * Only rows under the `invite:` prefix — the rest of this table is Better Auth's, and
136
+ * deleting its tokens would sign people out and break magic links.
137
+ */
138
+ export async function pruneInvitations(): Promise<void> {
139
+ await getDb()
140
+ .delete(verification)
141
+ .where(and(like(verification.identifier, `${KEY_PREFIX}%`), lt(verification.expiresAt, new Date())));
142
+ }
@@ -0,0 +1,71 @@
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
+ * Where a resource's rate limits are counted — the Cloudflare half.
12
+ *
13
+ * A descriptor says `rateLimit: { create: "10/min" }`; the generated route asks here
14
+ * whether this call is within it. The counters live in a Durable Object, one instance per
15
+ * resource and action holding a map of caller → count, so a limited call costs one object
16
+ * request rather than one per caller.
17
+ *
18
+ * **If the binding is missing, nothing is limited.** Say so loudly once rather than
19
+ * failing every request: an app that will not serve anything is worse than one that logs
20
+ * that a limit is not being enforced, and `flare doctor` reports it as a problem. The
21
+ * binding is in wrangler.jsonc as FLARE_RATE_LIMIT.
22
+ */
23
+ import { env } from "cloudflare:workers";
24
+ import { rateLimitKey, type Rate, type RateLimitDecision } from "@flaredev/core";
25
+ import type { RateLimitNamespace } from "@flaredev/core/rate-limit/server";
26
+
27
+ /** Allowed, with no count kept — what an unconfigured limiter answers. */
28
+ const UNLIMITED: RateLimitDecision = { allowed: true, remaining: Number.MAX_SAFE_INTEGER, retryAfter: 0 };
29
+
30
+ let warned = false;
31
+
32
+ /** The FLARE_RATE_LIMIT binding, or undefined when the app has none. */
33
+ function namespace(): RateLimitNamespace | undefined {
34
+ const binding = (env as { FLARE_RATE_LIMIT?: RateLimitNamespace } | undefined)?.FLARE_RATE_LIMIT;
35
+ if (binding) return binding;
36
+ if (!warned) {
37
+ warned = true;
38
+ console.warn(
39
+ "[flare] A resource declares a rateLimit, but there is no FLARE_RATE_LIMIT binding, so nothing is being limited. " +
40
+ "Add it to wrangler.jsonc (flare doctor will show you).",
41
+ );
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ /**
47
+ * Count one call and say whether it was within the limit.
48
+ *
49
+ * `caller` is the signed-in account's id where there is one and the client IP otherwise,
50
+ * so a limit is per person rather than per everyone behind one connection.
51
+ */
52
+ export async function checkRateLimit(
53
+ resource: string,
54
+ action: string,
55
+ caller: string,
56
+ rate: Rate,
57
+ ): Promise<RateLimitDecision> {
58
+ const binding = namespace();
59
+ if (!binding) return UNLIMITED;
60
+ try {
61
+ // One object per resource and action; the caller is a key inside it.
62
+ const stub = binding.get(binding.idFromName(`${resource}:${action}`));
63
+ return await stub.hit(rateLimitKey(resource, action, caller), rate);
64
+ } catch (error) {
65
+ // A counter that is unreachable must not take the endpoint down with it. Letting the
66
+ // call through is the safer failure: the alternative is an outage in the limiter
67
+ // becoming an outage in the app.
68
+ console.error("[flare] rate limit counter failed, allowing the request:", error);
69
+ return UNLIMITED;
70
+ }
71
+ }
@@ -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
 
@@ -13,6 +13,7 @@ import {
13
13
  crossOrigin,
14
14
  drain,
15
15
  failureResponse,
16
+ PROBLEM_CODES,
16
17
  problem,
17
18
  readJson,
18
19
  respond,
@@ -41,6 +42,12 @@ export interface ResourceHandlerOptions {
41
42
  authorize: Authorize;
42
43
  /** Who is making the request, for the descriptor's hooks. */
43
44
  currentUser?: ResourceStoreOptions["currentUser"];
45
+ /**
46
+ * Serve every row, whoever is asking. See {@link ResourceStoreOptions.system} — the
47
+ * store needs this or `currentUser`, and a route that genuinely has no session is the
48
+ * one case where it is the honest answer.
49
+ */
50
+ system?: ResourceStoreOptions["system"];
44
51
  /** Called after a write succeeds, for cache invalidation. */
45
52
  onChange?: (event: ChangeEvent) => void | Promise<void>;
46
53
  }
@@ -52,7 +59,7 @@ export function createResourceHandlers(options: ResourceHandlerOptions) {
52
59
 
53
60
  /** The cross-origin guard and the JSON read, in the order a write needs them. */
54
61
  const readBody = async (request: Request): Promise<{ body: unknown } | { response: Response }> =>
55
- crossOrigin(request) ? { response: problem(403, "Cross-origin request blocked.") } : readJson(request);
62
+ crossOrigin(request) ? { response: problem(403, "Cross-origin request blocked.", PROBLEM_CODES.crossOriginBlocked) } : readJson(request);
56
63
 
57
64
  const guard = async (context: AuthorizeContext) => (await authorize(context)) ?? undefined;
58
65
 
@@ -99,7 +106,7 @@ export function createResourceHandlers(options: ResourceHandlerOptions) {
99
106
  item(request, context, "update", (id) => withBody(request, async (body) => respond(await store.replace(id, body)))),
100
107
  DELETE: (request: Request, context: RouteContext) =>
101
108
  item(request, context, "delete", async (id) => {
102
- if (crossOrigin(request)) return problem(403, "Cross-origin request blocked.");
109
+ if (crossOrigin(request)) return problem(403, "Cross-origin request blocked.", PROBLEM_CODES.crossOriginBlocked);
103
110
  const result = await store.delete(id);
104
111
  return result.ok ? new Response(null, { status: 204 }) : failureResponse(result);
105
112
  }),
@@ -9,6 +9,11 @@
9
9
  // `flare update` applies the parts you choose. Neither runs unless you ask.
10
10
 
11
11
  import type { Resource } from "@flaredev/core";
12
+ import { PROBLEM_CODES, problemResponse, type ProblemCode, type ProblemIssue } from "@flaredev/core";
13
+
14
+ // Re-exported so a generated route reads every error code from `@/lib/resource` like
15
+ // everything else it uses. The route never imports the framework directly.
16
+ export { ISSUE_CODES, PROBLEM_CODES, type IssueCode, type ProblemCode, type ProblemIssue } from "@flaredev/core";
12
17
  import type { Failure, ResourceAction, Result } from "./store";
13
18
 
14
19
  export interface AuthorizeContext {
@@ -24,22 +29,26 @@ export type Authorize = (context: AuthorizeContext) => Response | void | Promise
24
29
  /** The context Next.js and vinext hand a route under `[id]`. */
25
30
  export type RouteContext = { params: Promise<{ id: string }> };
26
31
 
27
- /** A JSON error response. */
28
- export const problem = (status: number, error: string, extra: object = {}) => Response.json({ error, ...extra }, { status });
32
+ /**
33
+ * A JSON error response, in the one shape the whole API uses:
34
+ *
35
+ * { "error": "…", "code": "unique_violation", "issues": [{ "path": "…", "message": "…", "code": "unique" }] }
36
+ *
37
+ * `error` is the sentence a person reads and its wording may change; `code` is for a
38
+ * program and does not. `issues` is there whenever the answer is about fields. See
39
+ * PROBLEM_CODES for the codes, and the API reference for the contract.
40
+ */
41
+ export const problem = (status: number, error: string, code: ProblemCode, issues?: ProblemIssue[]) =>
42
+ problemResponse(status, error, code, issues);
29
43
 
30
44
  /**
31
45
  * A store failure as HTTP.
32
46
  *
33
- * The status is decided in store.ts, where the reason is known: 422 with field issues
34
- * for validation, 409 with the column for a unique conflict, 404 for a missing row.
47
+ * The status and the code are decided in store.ts, where the reason is known: 422 with
48
+ * field issues for validation, 409 naming the column for a unique conflict, 404 for a
49
+ * missing row.
35
50
  */
36
- export function failureResponse(failure: Failure): Response {
37
- if (failure.queryIssues) return problem(failure.status, failure.error, { issues: failure.queryIssues });
38
- const extra: Record<string, unknown> = {};
39
- if (failure.issues) extra.issues = failure.issues;
40
- if (failure.field) extra.field = failure.field;
41
- return problem(failure.status, failure.error, extra);
42
- }
51
+ export const failureResponse = (failure: Failure): Response => problemResponse(failure.status, failure.error, failure.code, failure.issues);
43
52
 
44
53
  /** A store result as HTTP: the data, or the failure above. */
45
54
  export const respond = <T>(result: Result<T>, init?: ResponseInit) => (result.ok ? Response.json(result.data, init) : failureResponse(result));
@@ -80,11 +89,13 @@ export const crossOrigin = (request: Request) => {
80
89
  */
81
90
  export async function readJson(request: Request): Promise<{ body: unknown } | { response: Response }> {
82
91
  const type = request.headers.get("content-type") ?? "";
83
- if (!/^application\/json\b/i.test(type)) return { response: problem(415, "Send JSON with Content-Type: application/json.") };
92
+ if (!/^application\/json\b/i.test(type)) {
93
+ return { response: problem(415, "Send JSON with Content-Type: application/json.", PROBLEM_CODES.unsupportedMediaType) };
94
+ }
84
95
  try {
85
96
  return { body: await request.json() };
86
97
  } catch {
87
- return { response: problem(400, "Request body isn't valid JSON.") };
98
+ return { response: problem(400, "Request body isn't valid JSON.", PROBLEM_CODES.invalidJson) };
88
99
  }
89
100
  }
90
101
 
@@ -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,8 @@
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
+ import { ISSUE_CODES, PROBLEM_CODES, type ProblemCode, type ProblemIssue } from "@flaredev/core";
12
13
  import { createValidators } from "@flaredev/core";
13
14
  import { encodeCursor, isSearchable, parseListQuery, type QueryIssue } from "./query";
14
15
  import type { ResourceRows, Row } from "./rows";
@@ -24,14 +25,19 @@ export interface FieldIssue {
24
25
 
25
26
  export type Failure = {
26
27
  ok: false;
27
- status: 400 | 403 | 404 | 409 | 422;
28
+ status: 400 | 401 | 403 | 404 | 409 | 422;
29
+ /** The sentence a person reads. Its wording is not part of the contract. */
28
30
  error: string;
29
- /** Validation issues per field. */
30
- issues?: FieldIssue[];
31
- /** Invalid query parameters. */
32
- queryIssues?: QueryIssue[];
33
- /** Field that caused a conflict (e.g. a unique violation). */
34
- field?: string;
31
+ /** Why, as a value a program can switch on. See PROBLEM_CODES. */
32
+ code: ProblemCode;
33
+ /**
34
+ * Which fields, whenever the answer is about fields at all: one issue for a unique
35
+ * conflict, one per failed rule for a validation error, one per bad parameter for a
36
+ * query. There used to be three ways of saying this — `issues` with a `path`, `issues`
37
+ * with a `param`, and a bare `field` — and a client had to handle whichever one the
38
+ * guard that refused it happened to use.
39
+ */
40
+ issues?: ProblemIssue[];
35
41
  };
36
42
 
37
43
  export type Result<T> = { ok: true; data: T } | Failure;
@@ -105,10 +111,21 @@ export interface ResourceStoreOptions {
105
111
  * perform the action at all is decided before the store is reached, by `authorize` on
106
112
  * the API and by the dashboard's own check.
107
113
  *
108
- * Ownership applies only when `currentUser` is also given. A store built without one
109
- * has no session to scope to, which is how a seed or a script writes rows for anybody.
114
+ * Ownership applies unless `system` says otherwise.
110
115
  */
111
116
  policy?: Policy | null;
117
+ /**
118
+ * Run unconfined, on purpose.
119
+ *
120
+ * A store with no session cannot scope anything to an owner, which is right for a
121
+ * seed, a cron job or a migration script and wrong for anything serving a request.
122
+ * The two used to look identical — leave `currentUser` off and an `own` policy simply
123
+ * stopped applying — so the dangerous case was the one you got by forgetting.
124
+ *
125
+ * Now it has to be said. Pass `currentUser`, or pass `system: true`; a store with
126
+ * neither throws where it is built rather than returning everybody's rows at runtime.
127
+ */
128
+ system?: true;
112
129
  /**
113
130
  * Called after a write succeeds, for cache invalidation. Runs before the
114
131
  * operation returns, so a caller that revalidates tags can't hand back a
@@ -129,6 +146,16 @@ export function createResourceStore(options: ResourceStoreOptions) {
129
146
  const validators = createValidators(resource);
130
147
  const fields = storedFields(resource);
131
148
 
149
+ // Before anything else, because the failure it prevents is silent. A store with no
150
+ // session and no `system` cannot confine an `own` policy, so it would hand back every
151
+ // row — correct for a seed, a hole in a server action somebody wrote in a hurry.
152
+ if (!options.currentUser && !options.system) {
153
+ throw new Error(
154
+ `Resource "${resource.name}": a store needs \`currentUser\`, or \`system: true\` to say it is meant to run unconfined. ` +
155
+ `Without one of them an \`own\` policy cannot be applied, and every row would be returned.`,
156
+ );
157
+ }
158
+
132
159
  // Drizzle can say up front whether the table matches the descriptor; a Prisma client
133
160
  // can't be asked the same question without a round trip, so that check stays here.
134
161
  const columns = "columns" in rows ? (rows.columns as Record<string, { name: string }>) : undefined;
@@ -146,7 +173,13 @@ export function createResourceStore(options: ResourceStoreOptions) {
146
173
  ? Object.entries(columns).find(([, column]) => column.name === name)?.[0]
147
174
  : ["id", "createdAt", "updatedAt", ...fields.map(([key]) => key)].find((key) => columnName(key) === name || key === name);
148
175
 
149
- const fail = (status: Failure["status"], error: string, extra: Partial<Failure> = {}): Failure => ({ ok: false, status, error, ...extra });
176
+ const fail = (status: Failure["status"], error: string, code: ProblemCode, issues?: ProblemIssue[]): Failure => ({
177
+ ok: false,
178
+ status,
179
+ error,
180
+ code,
181
+ ...(issues?.length ? { issues } : {}),
182
+ });
150
183
 
151
184
  /** The same wording whichever database refused the write. */
152
185
  function constraintFailure(error: unknown, action: ResourceAction): Failure | undefined {
@@ -155,14 +188,17 @@ export function createResourceStore(options: ResourceStoreOptions) {
155
188
  if (hit.kind === "unique") {
156
189
  const key = hit.column ? keyForColumn(hit.column) : undefined;
157
190
  const label = key ? (resource.fields[key]?.label ?? key) : "Value";
158
- return fail(409, `${label} is already taken.`, key ? { field: key } : {});
191
+ const error = `${label} is already taken.`;
192
+ // The column goes in as an issue, so a form reads one shape whether a rule or the
193
+ // database refused the value.
194
+ return fail(409, error, PROBLEM_CODES.uniqueViolation, key ? [{ path: key, message: error, code: ISSUE_CODES.unique }] : undefined);
159
195
  }
160
196
  if (hit.kind === "foreignKey") {
161
197
  return action === "delete"
162
- ? fail(409, `This ${resource.label.toLowerCase()} is still referenced by other records.`)
163
- : fail(422, "A related record doesn't exist.");
198
+ ? fail(409, `This ${resource.label.toLowerCase()} is still referenced by other records.`, PROBLEM_CODES.stillReferenced)
199
+ : fail(422, "A related record doesn't exist.", PROBLEM_CODES.relatedRecordMissing);
164
200
  }
165
- return fail(422, "A value is outside its allowed options.");
201
+ return fail(422, "A value is outside its allowed options.", PROBLEM_CODES.valueNotAllowed);
166
202
  }
167
203
 
168
204
  async function guarded<T>(action: ResourceAction, work: () => Promise<Result<T>>): Promise<Result<T>> {
@@ -196,9 +232,14 @@ export function createResourceStore(options: ResourceStoreOptions) {
196
232
  }
197
233
 
198
234
  const invalid = (issues: { path: PropertyKey[]; message: string }[]) =>
199
- fail(422, "Validation failed.", { issues: issues.map((issue) => ({ path: issue.path.map(String).join("."), message: issue.message })) });
235
+ fail(
236
+ 422,
237
+ "Validation failed.",
238
+ PROBLEM_CODES.validationFailed,
239
+ issues.map((issue) => ({ path: issue.path.map(String).join("."), message: issue.message, code: ISSUE_CODES.invalid })),
240
+ );
200
241
 
201
- const notFound = () => fail(404, `${resource.label} not found.`);
242
+ const notFound = () => fail(404, `${resource.label} not found.`, PROBLEM_CODES.notFound);
202
243
 
203
244
  /**
204
245
  * Money fields, and the currency each one counts in.
@@ -212,6 +253,34 @@ export function createResourceStore(options: ResourceStoreOptions) {
212
253
  (def.kind === "int" || def.kind === "float") && def.format === "money" ? [{ key, currency: def.currency }] : [],
213
254
  );
214
255
 
256
+ /**
257
+ * Fields that only apply when another field says so, e.g. a billing cycle that exists
258
+ * on a recurring project and not on a one-off one.
259
+ *
260
+ * The validator decides whether one is *required*; this decides whether it is *kept*.
261
+ * Without it, changing a project from recurring to one-off leaves last month's billing
262
+ * cycle sitting in the row — the form stopped showing the input, so nothing ever sent a
263
+ * value to overwrite it, and the record quietly disagrees with itself.
264
+ */
265
+ const conditionalFields = fields.flatMap(([key, def]) => (def.showWhen ? [{ key, condition: def.showWhen }] : []));
266
+
267
+ /**
268
+ * Null the fields that no longer apply.
269
+ *
270
+ * `record` is what the condition is judged against: the whole record as it will be once
271
+ * this write lands, which on a PATCH means the stored row with the patch laid over it.
272
+ * Judging the patch alone would read a field it never mentioned as absent and clear a
273
+ * value that was meant to stay.
274
+ */
275
+ function clearInapplicable(values: Row, record: Row): Row {
276
+ if (conditionalFields.length === 0) return values;
277
+ const out = { ...values };
278
+ for (const { key, condition } of conditionalFields) {
279
+ if (!conditionHolds(condition, record)) out[key] = null;
280
+ }
281
+ return out;
282
+ }
283
+
215
284
  /** Amounts as the database stores them: 19.99 -> 1999. */
216
285
  function toStored(values: Row): Row {
217
286
  if (moneyFields.length === 0) return values;
@@ -277,15 +346,17 @@ export function createResourceStore(options: ResourceStoreOptions) {
277
346
  /**
278
347
  * The owner this call is confined to: `null` to touch every row, or a Failure.
279
348
  *
280
- * Only when a `currentUser` was given — see the option. A policy that restricts rows
281
- * and a request with no session is a 403, not an unrestricted query: the API refuses
282
- * that earlier, but the dashboard and server actions come through here too, and a
283
- * missing session must never widen what a query returns.
349
+ * Unconfined only when the store said `system` — see the option. A policy that
350
+ * restricts rows and a request with no session is a 403, not an unrestricted query:
351
+ * the API refuses that earlier, but the dashboard and server actions come through here
352
+ * too, and a missing session must never widen what a query returns.
284
353
  */
285
354
  async function confine(): Promise<{ field: string; value: string } | Failure | null> {
286
- if (!ownership || !options.currentUser) return null;
287
- const user = await options.currentUser();
288
- if (!user) return fail(403, "Sign in required.");
355
+ // `system` is the only way past an ownership policy, and the constructor has already
356
+ // insisted that a store without one has a `currentUser` to confine to.
357
+ if (!ownership || options.system) return null;
358
+ const user = await options.currentUser!();
359
+ if (!user) return fail(401, "Sign in required.", PROBLEM_CODES.unauthenticated);
289
360
  return ownershipFilter(options.policy, user);
290
361
  }
291
362
 
@@ -303,7 +374,16 @@ export function createResourceStore(options: ResourceStoreOptions) {
303
374
  /** `params`: page, perPage, sort, q, filter[field], cursor (as parsed by parseListQuery). */
304
375
  async list(params: URLSearchParams, options: { maxPerPage?: number } = {}): Promise<Result<ListResult>> {
305
376
  const parsed = parseListQuery(resource, params, options);
306
- if ("issues" in parsed) return fail(400, "Invalid query.", { queryIssues: parsed.issues });
377
+ // A bad parameter is an issue like any other; it used to arrive under `param`
378
+ // rather than `path`, which was the fourth shape a client had to know about.
379
+ if ("issues" in parsed) {
380
+ return fail(
381
+ 400,
382
+ "Invalid query.",
383
+ PROBLEM_CODES.invalidQuery,
384
+ parsed.issues.map((issue) => ({ path: issue.param, message: issue.message, code: ISSUE_CODES.invalidParameter })),
385
+ );
386
+ }
307
387
  const { page, perPage, sort, q, cursor } = parsed.query;
308
388
 
309
389
  const limit = await confine();
@@ -405,16 +485,19 @@ export function createResourceStore(options: ResourceStoreOptions) {
405
485
  /**
406
486
  * Title-field values for a set of ids (for showing relations). Missing ids are omitted.
407
487
  *
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.
488
+ * Confined by ownership, like every other read: a relation picker shown to a user who
489
+ * only sees their own rows must not name anybody else's. An id they may not see is
490
+ * simply absent from the result, which the caller already handles — missing ids are
491
+ * omitted either way, so a title they are not entitled to looks the same as one that
492
+ * was deleted.
413
493
  */
414
494
  async titles(ids: string[]): Promise<Record<string, string>> {
415
495
  const unique = [...new Set(ids.filter(Boolean))];
416
496
  if (unique.length === 0) return {};
417
- const found = await rows.titles(unique, resource.titleField);
497
+ const limit = await confine();
498
+ // Not signed in where sign-in is required: no titles rather than everyone's.
499
+ if (isFailure(limit)) return {};
500
+ const found = await rows.titles(unique, resource.titleField, limit ?? undefined);
418
501
  return Object.fromEntries(found.map((row) => [row.id, row.title == null ? row.id : String(row.title)]));
419
502
  },
420
503
 
@@ -436,7 +519,8 @@ export function createResourceStore(options: ResourceStoreOptions) {
436
519
  if (!result.success) return invalid(result.error.issues);
437
520
  const input = result.data as Record<string, unknown>;
438
521
  const now = new Date();
439
- const record = await rows.insert(toStored({ ...input, id: crypto.randomUUID(), createdAt: now, updatedAt: now }));
522
+ const fresh = { ...input, id: crypto.randomUUID(), createdAt: now, updatedAt: now };
523
+ const record = await rows.insert(toStored(clearInapplicable(fresh, fresh)));
440
524
  const saved = withComputed(record);
441
525
  await hooks.afterCreate?.(saved, context);
442
526
  return { ok: true, data: saved };
@@ -452,7 +536,10 @@ export function createResourceStore(options: ResourceStoreOptions) {
452
536
 
453
537
  // A confined update has to read the row first: `rows.update(id, …)` takes an id
454
538
  // 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;
539
+ // Conditional fields join this list: deciding whether one still applies needs the
540
+ // whole record, and a PATCH on its own may not mention the field it depends on.
541
+ const needsCurrent = limit || hooks.beforeUpdate || hooks.afterUpdate || conditionalFields.length > 0;
542
+ const current = needsCurrent ? await rows.byId(id) : null;
456
543
  if (limit) {
457
544
  if (!current) return notFound();
458
545
  if (!owns(current, limit)) return notFound();
@@ -467,16 +554,17 @@ export function createResourceStore(options: ResourceStoreOptions) {
467
554
  const sent = (supplied as Record<string, unknown>)[limit.field];
468
555
  if (sent !== limit.value) {
469
556
  const label = resource.fields[limit.field]?.label ?? limit.field;
470
- return fail(422, `You can't change which user this ${resource.label.toLowerCase()} belongs to.`, {
471
- issues: [{ path: limit.field, message: `${label} can't be changed.` }],
472
- });
557
+ return fail(422, `You can't change which user this ${resource.label.toLowerCase()} belongs to.`, PROBLEM_CODES.validationFailed, [
558
+ { path: limit.field, message: `${label} can't be changed.`, code: ISSUE_CODES.immutable },
559
+ ]);
473
560
  }
474
561
  }
475
562
 
476
563
  const result = validators.update.safeParse(supplied);
477
564
  if (!result.success) return invalid(result.error.issues);
478
565
  const input = result.data as Record<string, unknown>;
479
- const record = await rows.update(id, toStored({ ...input, updatedAt: new Date() }));
566
+ const merged = { ...((current ?? {}) as Row), ...input };
567
+ const record = await rows.update(id, toStored(clearInapplicable({ ...input, updatedAt: new Date() }, merged)));
480
568
  if (!record) return notFound();
481
569
  const saved = withComputed(record);
482
570
  await hooks.afterUpdate?.(saved, { ...context, previous: current });
@@ -505,7 +593,7 @@ export function createResourceStore(options: ResourceStoreOptions) {
505
593
  // one of them, and a replace would quietly orphan the row it just saved.
506
594
  if (limit) values[limit.field] = limit.value;
507
595
 
508
- const record = await rows.update(id, toStored(values));
596
+ const record = await rows.update(id, toStored(clearInapplicable(values, values)));
509
597
  return record ? { ok: true, data: withComputed(record) } : notFound();
510
598
  });
511
599
  },