@flaredev/cli 0.8.0 → 0.9.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 (33) hide show
  1. package/dist/index.js +2488 -1796
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/actions.ts +45 -5
  4. package/templates/app/components/dashboard/fields/field-widget.tsx +34 -2
  5. package/templates/app/components/dashboard/fields/json-field.tsx +90 -0
  6. package/templates/app/components/dashboard/fields/markdown-field.tsx +82 -0
  7. package/templates/app/components/dashboard/fields/markdown.tsx +159 -0
  8. package/templates/app/components/dashboard/fields/tags-field.tsx +100 -0
  9. package/templates/app/components/dashboard/import-dialog.tsx +19 -0
  10. package/templates/app/components/dashboard/record-actions.tsx +4 -1
  11. package/templates/app/components/dashboard/record-detail.tsx +18 -0
  12. package/templates/app/components/dashboard/resource-form-page.tsx +5 -1
  13. package/templates/app/components/dashboard/resource-form-sheet.tsx +16 -2
  14. package/templates/app/components/dashboard/resource-form.tsx +11 -2
  15. package/templates/app/components/dashboard/resource-table.tsx +21 -2
  16. package/templates/app/components/dashboard/row-actions.tsx +42 -8
  17. package/templates/app/components/dashboard/trash-toggle.tsx +30 -0
  18. package/templates/app/lib/api-keys.ts +61 -0
  19. package/templates/app/lib/api.ts +35 -9
  20. package/templates/app/lib/dashboard.ts +36 -7
  21. package/templates/app/lib/realtime.ts +5 -0
  22. package/templates/app/lib/resource/drizzle-rows.ts +9 -2
  23. package/templates/app/lib/resource/http.ts +19 -3
  24. package/templates/app/lib/resource/query.ts +29 -3
  25. package/templates/app/lib/resource/rows.ts +11 -1
  26. package/templates/app/lib/resource/store.ts +178 -15
  27. package/templates/locks/package-lock.json +266 -260
  28. package/templates/locks/pnpm-lock.yaml +328 -322
  29. package/templates/locks/yarn.lock +285 -285
  30. package/templates/next/app/dashboard/actions.ts +45 -5
  31. package/templates/next/lib/dashboard.ts +38 -7
  32. package/templates/next/lib/realtime.ts +28 -5
  33. package/templates/next/lib/resource/prisma-rows.ts +5 -1
@@ -1,6 +1,6 @@
1
1
  import { notFound } from "next/navigation";
2
2
  import { clientResource, formatValue, storedFields, type Resource } from "@flaredev/core";
3
- import { resourcePath, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
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";
@@ -8,6 +8,9 @@ import { ResourceForm } from "./resource-form";
8
8
  /** Server wrapper for create/edit pages: loads the record and relation titles, renders the heading and form. */
9
9
  export async function ResourceFormPage({ resource, id }: { resource: Resource; id?: string }) {
10
10
  await requireAccess(resource, id ? "update" : "create");
11
+ // A field this user is confined by is set from their session, so the form leaves it out.
12
+ const owned = await confinedField(resource.name);
13
+ const omit = owned ? [owned] : undefined;
11
14
  // Hooks and computed values are functions, and a function can't be sent to the browser.
12
15
  // This is the same resource without them, for the client components below.
13
16
  const forClient = clientResource(resource);
@@ -58,6 +61,7 @@ export async function ResourceFormPage({ resource, id }: { resource: Resource; i
58
61
  record={record}
59
62
  relations={relations}
60
63
  listHref={resourcePath(resource)}
64
+ omit={omit}
61
65
  />
62
66
  </div>
63
67
  );
@@ -18,6 +18,8 @@ interface Props {
18
18
  /** Edit: the record and its id. */
19
19
  id?: string;
20
20
  record?: Record<string, unknown> | null;
21
+ /** Fields to leave out — see ResourceForm. */
22
+ omit?: string[];
21
23
  /** Controlled use (a row menu opens it); otherwise pass `trigger`. */
22
24
  open?: boolean;
23
25
  onOpenChange?: (open: boolean) => void;
@@ -33,7 +35,7 @@ interface Props {
33
35
  * the fields being squeezed into a box. Apps that would rather have a page set
34
36
  * `site.dashboard.forms` to "page" (lib/site.ts) and get the same form full width.
35
37
  */
36
- export function ResourceFormSheet({ resource, relations, listHref, mode, id, record, open, onOpenChange, trigger }: Props) {
38
+ export function ResourceFormSheet({ resource, relations, listHref, mode, id, record, omit, open, onOpenChange, trigger }: Props) {
37
39
  const [internalOpen, setInternalOpen] = useState(false);
38
40
  const isOpen = open ?? internalOpen;
39
41
  const setOpen = onOpenChange ?? setInternalOpen;
@@ -58,6 +60,7 @@ export function ResourceFormSheet({ resource, relations, listHref, mode, id, rec
58
60
  record={record}
59
61
  relations={relations}
60
62
  listHref={listHref}
63
+ omit={omit}
61
64
  onDone={() => setOpen(false)}
62
65
  />
63
66
  </div>
@@ -68,12 +71,23 @@ export function ResourceFormSheet({ resource, relations, listHref, mode, id, rec
68
71
  }
69
72
 
70
73
  /** The list's "New …" button, with the sheet it opens. */
71
- export function NewRecordButton({ resource, relations, listHref }: { resource: ClientResource; relations: FormRelations; listHref: string }) {
74
+ export function NewRecordButton({
75
+ resource,
76
+ relations,
77
+ listHref,
78
+ omit,
79
+ }: {
80
+ resource: ClientResource;
81
+ relations: FormRelations;
82
+ listHref: string;
83
+ omit?: string[];
84
+ }) {
72
85
  return (
73
86
  <ResourceFormSheet
74
87
  resource={resource}
75
88
  relations={relations}
76
89
  listHref={listHref}
90
+ omit={omit}
77
91
  mode="create"
78
92
  trigger={
79
93
  <Button>
@@ -27,6 +27,14 @@ export interface ResourceFormProps {
27
27
  listHref: string;
28
28
  /** In a sheet: close it instead of navigating, and drop the card around the fields. */
29
29
  onDone?: () => void;
30
+ /**
31
+ * Fields to leave out.
32
+ *
33
+ * The owner field of a resource with per-record ownership, for the users it confines:
34
+ * the store sets it from their session, so an input for it would discard whatever they
35
+ * typed. A field that ignores you is worse than no field.
36
+ */
37
+ omit?: string[];
30
38
  }
31
39
 
32
40
  const WIDE = new Set(["text", "file"]);
@@ -57,10 +65,11 @@ function toSteps(fields: Entry[]): Entry[][] {
57
65
  * with the descriptor's zod schemas, then again on the server by the same store the
58
66
  * REST API uses; server errors (e.g. unique conflicts) land on their fields.
59
67
  */
60
- export function ResourceForm({ resource, mode, id, record, relations = {}, listHref, onDone }: ResourceFormProps) {
68
+ export function ResourceForm({ resource, mode, id, record, relations = {}, listHref, onDone, omit }: ResourceFormProps) {
61
69
  const router = useRouter();
62
70
  const validators = useMemo(() => createValidators(resource), [resource]);
63
- const fields = useMemo(() => storedFields(resource), [resource]);
71
+ const hidden = useMemo(() => new Set(omit ?? []), [omit]);
72
+ const fields = useMemo(() => storedFields(resource).filter(([key]) => !hidden.has(key)), [resource, hidden]);
64
73
  const steps = useMemo(() => toSteps(fields), [fields]);
65
74
  const [step, setStep] = useState(0);
66
75
  const [values, setValues] = useState(() => initialFormValues(resource, record));
@@ -16,11 +16,12 @@ import {
16
16
  PaginationPrevious,
17
17
  } from "@/components/ui/pagination";
18
18
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
19
- import { resourcePath, adminPermissions, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
19
+ import { resourcePath, adminPermissions, allResources, confinedField, dashboardStore, requireAccess } from "@/lib/dashboard";
20
20
  import { site } from "@/lib/site";
21
21
  import { cn } from "@/lib/utils";
22
22
  import { ColumnMenu } from "./column-menu";
23
23
  import { ExportButton } from "./export-button";
24
+ import { TrashToggle } from "./trash-toggle";
24
25
  import { isInlineEditable } from "./editable";
25
26
  import { InlineCell } from "./inline-cell";
26
27
  import type { RelationMeta } from "./fields/field-widget";
@@ -50,7 +51,12 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
50
51
  const forClient = clientResource(resource);
51
52
  const basePath = resourcePath(resource);
52
53
  const permissions = await adminPermissions(resource.name);
54
+ // A field this user is confined by is set from their session, so the forms leave it out.
55
+ const owned = await confinedField(resource.name);
56
+ const omit = owned ? [owned] : undefined;
53
57
  const params = toSearchParams(searchParams);
58
+ // Which view this is. State in the URL like ?page and ?sort, so the trash is linkable.
59
+ const viewingTrash = resource.softDelete && params.get("deleted") === "only";
54
60
  const store = dashboardStore(resource.name);
55
61
 
56
62
  let result = await store.list(params);
@@ -108,13 +114,14 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
108
114
  <div className="flex flex-wrap items-center justify-between gap-2">
109
115
  <ResourceTableToolbar resource={forClient} />
110
116
  <div className="flex flex-wrap items-center gap-2">
117
+ {resource.softDelete && <TrashToggle basePath={basePath} viewingTrash={viewingTrash} />}
111
118
  <SaveViewButton resourceName={resource.name} label={resource.pluralLabel} />
112
119
  <ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
113
120
  <ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
114
121
  {permissions.create && <ImportDialog resource={forClient} />}
115
122
  {permissions.create &&
116
123
  (overlayForms ? (
117
- <NewRecordButton resource={forClient} relations={relations} listHref={basePath} />
124
+ <NewRecordButton resource={forClient} relations={relations} listHref={basePath} omit={omit} />
118
125
  ) : (
119
126
  <Button asChild>
120
127
  <Link href={resourcePath(resource, "new")}>
@@ -212,6 +219,16 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
212
219
  // A thumbnail for an image, a filename for anything else — the raw
213
220
  // object key is noise in a table.
214
221
  content = <FileCell resourceName={resource.name} fieldKey={column.key} value={value} />;
222
+ } else if (column.def.kind === "tags" && Array.isArray(value) && value.length) {
223
+ content = (
224
+ <span className="flex flex-wrap gap-1">
225
+ {value.map((item) => (
226
+ <Badge key={String(item)} variant="outline">
227
+ {String(item)}
228
+ </Badge>
229
+ ))}
230
+ </span>
231
+ );
215
232
  } else if (column.def.kind === "multiselect" && Array.isArray(value) && value.length) {
216
233
  const def = column.def;
217
234
  content = (
@@ -293,6 +310,8 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
293
310
  id={id}
294
311
  record={overlayForms ? row : undefined}
295
312
  relations={relations}
313
+ omit={omit}
314
+ inTrash={viewingTrash}
296
315
  listHref={basePath}
297
316
  editHref={resourcePath(resource, id, "edit")}
298
317
  detailHref={resourcePath(resource, id)}
@@ -3,10 +3,10 @@
3
3
  import Link from "next/link";
4
4
  import { useRouter } from "next/navigation";
5
5
  import { useState, useTransition } from "react";
6
- import { ExternalLinkIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
6
+ import { ExternalLinkIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon, Undo2Icon as UndoIcon } from "lucide-react";
7
7
  import { toast } from "sonner";
8
8
  import type { ClientResource } from "@flaredev/core";
9
- import { deleteRecordAction } from "@/app/dashboard/actions";
9
+ import { deleteRecordAction, restoreRecordAction } from "@/app/dashboard/actions";
10
10
  import {
11
11
  AlertDialog,
12
12
  AlertDialogCancel,
@@ -37,23 +37,29 @@ export function RowActions({
37
37
  id,
38
38
  record,
39
39
  relations = {},
40
+ omit,
40
41
  listHref,
41
42
  editHref,
42
43
  detailHref,
43
44
  canUpdate = true,
44
45
  canDelete = true,
46
+ inTrash = false,
45
47
  }: {
46
48
  resource: ClientResource;
47
49
  id: string;
48
50
  /** The record itself, when the form opens in a dialog. */
49
51
  record?: Record<string, unknown>;
50
52
  relations?: FormRelations;
53
+ /** Fields the edit form leaves out — see ResourceForm. */
54
+ omit?: string[];
51
55
  listHref: string;
52
56
  editHref: string;
53
57
  /** The record's own page. */
54
58
  detailHref: string;
55
59
  canUpdate?: boolean;
56
60
  canDelete?: boolean;
61
+ /** Viewing the trash of a softDelete resource: restore and empty, not edit and delete. */
62
+ inTrash?: boolean;
57
63
  }) {
58
64
  const router = useRouter();
59
65
  const [confirming, setConfirming] = useState(false);
@@ -63,9 +69,10 @@ export function RowActions({
63
69
 
64
70
  function onDelete() {
65
71
  startTransition(async () => {
66
- const result = await deleteRecordAction(resourceName, id);
72
+ // In the trash, Delete means for good — there is nowhere further to move it.
73
+ const result = await deleteRecordAction(resourceName, id, { force: inTrash });
67
74
  if (result.ok) {
68
- toast.success(`${label} deleted.`);
75
+ toast.success(inTrash ? `${label} deleted for good.` : `${label} moved to the trash.`);
69
76
  setConfirming(false);
70
77
  router.refresh();
71
78
  } else {
@@ -74,6 +81,18 @@ export function RowActions({
74
81
  });
75
82
  }
76
83
 
84
+ function onRestore() {
85
+ startTransition(async () => {
86
+ const result = await restoreRecordAction(resourceName, id);
87
+ if (result.ok) {
88
+ toast.success(`${label} restored.`);
89
+ router.refresh();
90
+ } else {
91
+ toast.error(result.error);
92
+ }
93
+ });
94
+ }
95
+
77
96
 
78
97
 
79
98
  return (
@@ -94,7 +113,15 @@ export function RowActions({
94
113
  </DropdownMenuItem>
95
114
  </DropdownMenuGroup>
96
115
  <DropdownMenuSeparator />
97
- {canUpdate && (
116
+ {canUpdate && inTrash && (
117
+ <DropdownMenuGroup>
118
+ <DropdownMenuItem onSelect={onRestore}>
119
+ <UndoIcon />
120
+ Restore
121
+ </DropdownMenuItem>
122
+ </DropdownMenuGroup>
123
+ )}
124
+ {canUpdate && !inTrash && (
98
125
  <DropdownMenuGroup>
99
126
  {record ? (
100
127
  <DropdownMenuItem onSelect={() => setEditing(true)}>
@@ -116,7 +143,7 @@ export function RowActions({
116
143
  <DropdownMenuGroup>
117
144
  <DropdownMenuItem variant="destructive" onSelect={() => setConfirming(true)}>
118
145
  <Trash2Icon />
119
- Delete
146
+ {inTrash ? "Delete forever" : "Delete"}
120
147
  </DropdownMenuItem>
121
148
  </DropdownMenuGroup>
122
149
  )}
@@ -127,6 +154,7 @@ export function RowActions({
127
154
  <ResourceFormSheet
128
155
  resource={resource}
129
156
  relations={relations}
157
+ omit={omit}
130
158
  listHref={listHref}
131
159
  mode="edit"
132
160
  id={id}
@@ -139,8 +167,14 @@ export function RowActions({
139
167
  <AlertDialog open={confirming} onOpenChange={setConfirming}>
140
168
  <AlertDialogContent>
141
169
  <AlertDialogHeader>
142
- <AlertDialogTitle>Delete this {label.toLowerCase()}?</AlertDialogTitle>
143
- <AlertDialogDescription>This can&apos;t be undone.</AlertDialogDescription>
170
+ <AlertDialogTitle>
171
+ {inTrash ? `Delete this ${label.toLowerCase()} for good?` : `Delete this ${label.toLowerCase()}?`}
172
+ </AlertDialogTitle>
173
+ <AlertDialogDescription>
174
+ {inTrash
175
+ ? "The record and its uploaded files are removed. This cannot be undone."
176
+ : "It moves to the trash, where you can restore it."}
177
+ </AlertDialogDescription>
144
178
  </AlertDialogHeader>
145
179
  <AlertDialogFooter>
146
180
  <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
@@ -0,0 +1,30 @@
1
+ import Link from "next/link";
2
+ import { ListIcon, Trash2Icon } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+
5
+ /**
6
+ * Switch a `softDelete` resource's list between its records and its trash.
7
+ *
8
+ * Two links rather than a control with state: the view is `?deleted=only` in the URL, like
9
+ * `?page` and `?sort`, so it is linkable, it survives a reload, and the back button means
10
+ * what it looks like it means. Dropping the other parameters is deliberate — a filter or a
11
+ * page number from the live list rarely makes sense against the trash.
12
+ */
13
+ export function TrashToggle({ basePath, viewingTrash }: { basePath: string; viewingTrash: boolean }) {
14
+ return (
15
+ <div className="flex items-center gap-1" role="group" aria-label="Which records to show">
16
+ <Button asChild size="sm" variant={viewingTrash ? "ghost" : "secondary"}>
17
+ <Link href={basePath} aria-current={viewingTrash ? undefined : "page"}>
18
+ <ListIcon data-icon="inline-start" />
19
+ Records
20
+ </Link>
21
+ </Button>
22
+ <Button asChild size="sm" variant={viewingTrash ? "secondary" : "ghost"}>
23
+ <Link href={`${basePath}?deleted=only`} aria-current={viewingTrash ? "page" : undefined}>
24
+ <Trash2Icon data-icon="inline-start" />
25
+ Trash
26
+ </Link>
27
+ </Button>
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,61 @@
1
+ // API keys for non-browser clients — off until `flare gen apikeys`.
2
+ //
3
+ // A cookie works for a browser and for nothing else. A cron job, a mobile app, a partner
4
+ // integration or a shell script needs a credential it can put in a header, and this is
5
+ // where one is turned into a user.
6
+ //
7
+ // Until `flare gen apikeys` runs there is no key plugin and no apikey table, so
8
+ // `userFromApiKey` finds nothing and every request is authenticated by its cookie exactly
9
+ // as before. The call stays in `lib/api.ts` either way, so switching keys on does not
10
+ // change the shape of authorize().
11
+ import { auth } from "./auth";
12
+
13
+ /** Where a key may arrive. `Authorization: Bearer <key>` is the convention most clients expect. */
14
+ export function keyFromRequest(headers: Headers): string | null {
15
+ const direct = headers.get("x-api-key");
16
+ if (direct) return direct;
17
+ const bearer = headers.get("authorization");
18
+ const match = bearer?.match(/^Bearer\s+(.+)$/i);
19
+ return match?.[1] ?? null;
20
+ }
21
+
22
+ /**
23
+ * The user a request's API key belongs to, or null.
24
+ *
25
+ * Deliberately not Better Auth's `enableSessionForAPIKeys`. That option mocks a session
26
+ * for any request carrying a valid key, and its own documentation says it is not
27
+ * recommended for production — so the key is verified explicitly here instead, where the
28
+ * check is readable and the failure cases are visible.
29
+ *
30
+ * `verifyApiKey` is where expiry, revocation, the per-key rate limit and the request
31
+ * count all happen, so every call has to go through it rather than reading the table.
32
+ */
33
+ export async function userFromApiKey(headers: Headers): Promise<{ id: string; email: string; role: string | null } | null> {
34
+ const key = keyFromRequest(headers);
35
+ if (!key) return null;
36
+
37
+ // Loosely typed on purpose: `verifyApiKey` only exists once the apiKey plugin is in
38
+ // lib/auth.ts, and this file ships before it. Absent, keys are simply not in use.
39
+ const api = auth.api as {
40
+ verifyApiKey?: (input: { body: { key: string }; headers: Headers }) => Promise<{ valid: boolean; error?: unknown; key?: { referenceId?: string } | null }>;
41
+ };
42
+ if (!api.verifyApiKey) return null;
43
+
44
+ // `headers` is required, not optional: this app resolves its baseURL from the request
45
+ // (lib/auth.ts), and a direct auth.api call without them throws rather than returning
46
+ // invalid — which would read as "wrong key" for a configuration problem.
47
+ const verified = await api.verifyApiKey({ body: { key }, headers }).catch((error: unknown) => {
48
+ console.error("[flare] verifying an API key failed:", error);
49
+ return null;
50
+ });
51
+ const referenceId = verified?.valid ? verified.key?.referenceId : undefined;
52
+ if (!referenceId) return null;
53
+
54
+ // Through Better Auth's own adapter, so this is the same call on D1 and on Postgres.
55
+ // The role matters: policies are checked against it, and a key with no role would be
56
+ // refused by every policy including `["*"]`.
57
+ const context = await auth.$context;
58
+ const user = await context.internalAdapter.findUserById(referenceId);
59
+ if (!user) return null;
60
+ return { id: user.id, email: user.email, role: (user as { role?: string | null }).role ?? null };
61
+ }
@@ -2,17 +2,37 @@ import { headers } from "next/headers";
2
2
  import { can, type Policy } from "@flaredev/core";
3
3
  import type { Authorize } from "@/lib/resource/handlers";
4
4
  import { policies } from "@/policies";
5
+ import { userFromApiKey } from "./api-keys";
5
6
  import { auth } from "./auth";
6
7
 
7
8
  /**
8
9
  * Who is making this request, for a resource's hooks (`hooks.beforeCreate` and friends
9
- * in the descriptor). Reads the session the same way everything else does.
10
+ * in the descriptor) and for per-record ownership in the store.
11
+ *
12
+ * The cookie first, then an API key. The store reads this to decide which rows a request
13
+ * may touch, so a key-authenticated call has to resolve to the same user a cookie would
14
+ * — otherwise a policy with `own` would refuse it.
10
15
  */
11
16
  export async function currentUser() {
12
- const session = await auth.api.getSession({ headers: await headers() });
13
- if (!session) return null;
14
- const { id, email } = session.user;
15
- return { id, email, role: (session.user as { role?: string | null }).role ?? null };
17
+ const incoming = await headers();
18
+ const session = await auth.api.getSession({ headers: incoming });
19
+ if (session) {
20
+ const { id, email } = session.user;
21
+ return { id, email, role: (session.user as { role?: string | null }).role ?? null };
22
+ }
23
+ return userFromApiKey(incoming);
24
+ }
25
+
26
+ /**
27
+ * A resource's policy, or undefined when it has none.
28
+ *
29
+ * Routes pass this to `createResourceStore` as well as to `authorize`, because the two
30
+ * halves of a policy are enforced in different places: the role check is a 403 before
31
+ * anything is read, and `own` is a restriction on which rows exist as far as this user
32
+ * is concerned, which only the store can apply.
33
+ */
34
+ export function policyFor(resourceName: string): Policy | undefined {
35
+ return (policies as Record<string, Policy | undefined>)[resourceName];
16
36
  }
17
37
 
18
38
  /**
@@ -23,14 +43,20 @@ export async function currentUser() {
23
43
  * (`flare gen policy <Resource> --roles admin,staff`); resources without a policy
24
44
  * are open to any signed-in user. The admin UI reads the same policies, so the two
25
45
  * never drift apart.
46
+ *
47
+ * This is the role half only. A policy with `own` also restricts which rows each user
48
+ * sees, and that is enforced by the store — see `policyFor`.
49
+ *
50
+ * A request may authenticate with a cookie or with an API key (`flare gen apikeys`).
51
+ * Either way it is a user with a role, and the policy is the same one.
26
52
  */
27
53
  export const authorize: Authorize = async ({ request, resource, action }) => {
28
54
  const session = await auth.api.getSession({ headers: request.headers });
29
- if (!session) return Response.json({ error: "Sign in required." }, { status: 401 });
55
+ const viaKey = session ? null : await userFromApiKey(request.headers);
56
+ if (!session && !viaKey) return Response.json({ error: "Sign in required." }, { status: 401 });
30
57
 
31
- const role = (session.user as { role?: string | null }).role ?? null;
32
- const policy = (policies as Record<string, Policy | undefined>)[resource.name];
33
- if (!can(policy, role, action)) {
58
+ const role = session ? ((session.user as { role?: string | null }).role ?? null) : viaKey!.role;
59
+ if (!can(policyFor(resource.name), role, action)) {
34
60
  return Response.json({ error: `Your role can't ${action} ${resource.pluralLabel.toLowerCase()}.` }, { status: 403 });
35
61
  }
36
62
  };
@@ -1,7 +1,7 @@
1
1
  import { headers } from "next/headers";
2
2
  import { notFound, redirect } from "next/navigation";
3
- import { allowedActions, can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
4
- import { count, sql } from "drizzle-orm";
3
+ import { allowedActions, can, ownershipFilter, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
4
+ import { count, eq, sql } from "drizzle-orm";
5
5
  import type { SQLiteTable } from "drizzle-orm/sqlite-core";
6
6
  import { createResourceStore, drizzleRows, type ResourceStore } from "@/lib/resource";
7
7
  import { getDb } from "@/db";
@@ -58,6 +58,17 @@ export async function adminPermissions(resourceName: string) {
58
58
  return allowedActions(policyFor(resourceName), role);
59
59
  }
60
60
 
61
+ /**
62
+ * The field the current user is confined by on this resource, or undefined.
63
+ *
64
+ * Forms leave it out: the store sets it from the session, so an input for it would throw
65
+ * away whatever was typed into it. Everyone exempt from `own` sees and edits it normally,
66
+ * because for them it really is just a field.
67
+ */
68
+ export async function confinedField(resourceName: string): Promise<string | undefined> {
69
+ return ownershipFilter(policyFor(resourceName), await currentUser())?.field;
70
+ }
71
+
61
72
  const stores = new Map<string, ResourceStore>();
62
73
 
63
74
  /** CRUD store for a resource by name (404 for unknown names). */
@@ -66,7 +77,16 @@ export function dashboardStore(name: string): ResourceStore {
66
77
  if (!entry) notFound();
67
78
  let store = stores.get(name);
68
79
  if (!store) {
69
- store = createResourceStore({ resource: entry.resource, rows: drizzleRows(entry.table, getDb), onChange: revalidateResource, currentUser });
80
+ store = createResourceStore({
81
+ resource: entry.resource,
82
+ rows: drizzleRows(entry.table, getDb),
83
+ // The admin is not exempt from ownership. A policy that confines staff to their own
84
+ // rows has to mean the same thing here as it does over the API, or the dashboard is
85
+ // a way around it.
86
+ policy: policyFor(name),
87
+ onChange: revalidateResource,
88
+ currentUser,
89
+ });
70
90
  stores.set(name, store);
71
91
  }
72
92
  return store;
@@ -93,9 +113,16 @@ export interface ResourceStats {
93
113
  values: Record<string, number>;
94
114
  }
95
115
 
96
- export function resourceStats(name: string, field?: string, days = 7): Promise<ResourceStats> {
116
+ export async function resourceStats(name: string, field?: string, days = 7): Promise<ResourceStats> {
117
+ // Counted over the rows this user may see, not the table. A policy with `own` that the
118
+ // table above obeys and the cards above it don't would report 142 invoices to someone
119
+ // who owns three — wrong, and a leak besides. The owner is an argument rather than a
120
+ // closed-over value because unstable_cache keys on the arguments: without it one user's
121
+ // numbers would be served to the next.
122
+ const limit = ownershipFilter(policyFor(name), await currentUser());
123
+
97
124
  const read = cached(
98
- async (resourceName: string, key: string | undefined, windowDays: number): Promise<ResourceStats> => {
125
+ async (resourceName: string, key: string | undefined, windowDays: number, owner?: { field: string; value: string }): Promise<ResourceStats> => {
99
126
  const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
100
127
  if (!entry) return { total: 0, current: 0, previous: 0, values: {} };
101
128
 
@@ -118,9 +145,11 @@ export function resourceStats(name: string, field?: string, days = 7): Promise<R
118
145
 
119
146
  const select: Record<string, unknown> = { total: count(), ...windows };
120
147
  if (grouped) select.value = grouped;
121
- const query = getDb()
148
+ let query = getDb()
122
149
  .select(select as never)
123
150
  .from(entry.table);
151
+ const ownerColumn = owner ? columns[owner.field] : undefined;
152
+ if (owner && ownerColumn) query = query.where(eq(ownerColumn as never, owner.value)) as never;
124
153
  const rows = (await (grouped ? query.groupBy(grouped as never) : query)) as {
125
154
  total: number;
126
155
  current: number;
@@ -140,7 +169,7 @@ export function resourceStats(name: string, field?: string, days = 7): Promise<R
140
169
  ["flare", "resource-stats"],
141
170
  { tags: [resourceTag(name)], revalidate: TTL.medium },
142
171
  );
143
- return read(name, field, days);
172
+ return read(name, field, days, limit ?? undefined);
144
173
  }
145
174
 
146
175
  /** How many records a resource has. One field of {@link resourceStats}. */
@@ -7,6 +7,11 @@ import {
7
7
  import { env } from "cloudflare:workers";
8
8
  import { auth } from "./auth";
9
9
 
10
+ // Re-exported so `import type { RealtimeChannel } from "@/lib/realtime"` compiles on both
11
+ // stacks. The Next.js copy has no hub to import it from and declares the same shape, so
12
+ // without this a type that is identical in both apps would only be importable in one.
13
+ export type { RealtimeChannel } from "@flaredev/core/realtime/server";
14
+
10
15
  /**
11
16
  * Who may open a realtime connection, per channel. `worker/index.ts` calls this for
12
17
  * every `/realtime/<channel>/ws` upgrade, after rejecting cross-site origins.
@@ -15,7 +15,7 @@
15
15
  * itself has no dialect in it. Behaviour is unchanged on purpose: the same LIKE search,
16
16
  * the same `(sort, id)` cursor comparison, the same capped count.
17
17
  */
18
- import { and, asc, count, desc, eq, getTableColumns, inArray, isNull, or, sql, type SQL } from "drizzle-orm";
18
+ import { and, asc, count, desc, eq, getTableColumns, inArray, isNotNull, isNull, or, sql, type SQL } from "drizzle-orm";
19
19
  import type { BaseSQLiteDatabase, SQLiteColumn, SQLiteTable } from "drizzle-orm/sqlite-core";
20
20
  import type { ConstraintHit, ResourceRows, Row, RowsQuery } from "./rows";
21
21
 
@@ -43,8 +43,15 @@ export function drizzleRows(table: SQLiteTable, getDb: () => AnyDatabase): Resou
43
43
  const byId = (id: string) => eq(idColumn, id);
44
44
 
45
45
  /** The search and filter half of a where clause — everything but the cursor. */
46
- function matching(query: Pick<RowsQuery, "search" | "filters">): SQL[] {
46
+ function matching(query: Pick<RowsQuery, "search" | "filters" | "deleted">): SQL[] {
47
47
  const conditions: SQL[] = [];
48
+ // A soft-deleting resource hides its deleted rows. `filters` cannot say this: it means
49
+ // "equals, or is null", and "only" needs the opposite of its null case.
50
+ const deletedAt = columns.deletedAt;
51
+ if (query.deleted && deletedAt) {
52
+ if (query.deleted === "exclude") conditions.push(isNull(deletedAt));
53
+ else if (query.deleted === "only") conditions.push(isNotNull(deletedAt));
54
+ }
48
55
  if (query.search && query.search.fields.length > 0) {
49
56
  const pattern = `%${query.search.term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
50
57
  const searchable = query.search.fields.map((key) => columns[key]!);
@@ -48,12 +48,28 @@ export const respond = <T>(result: Result<T>, init?: ResponseInit) => (result.ok
48
48
  * Whether this write came from another site.
49
49
  *
50
50
  * Browsers always send `Origin` on POST, PATCH, PUT and DELETE, so a mismatch is a
51
- * cross-site request and a request with no Origin at all is not from a browser form.
52
- * This is the CSRF guard; a route that writes should call it.
51
+ * cross-site request, and a request with no `Origin` at all is not from a browser form —
52
+ * which is why a script or a cron job with an API key is not caught by this. It is the
53
+ * CSRF guard; a route that writes should call it.
54
+ *
55
+ * `request.url` alone is not enough to compare against. On the Next.js stack it is built
56
+ * from the server's own idea of its address and ignores the `Host` header entirely: a
57
+ * request to `http://127.0.0.1:3000` arrives with `request.url` saying `localhost:3000`,
58
+ * so a page served from one of those addresses could not write to the other. The host the
59
+ * client actually addressed is in the headers, and either answer matching is same-site.
60
+ *
61
+ * Trusting a forwarded header is sound here, because of what this guard is for. It only
62
+ * has to hold against a browser, and a page cannot set `Origin` or `X-Forwarded-Host` on
63
+ * a request it makes. Anything that can set both already sends no cookie worth stealing.
53
64
  */
54
65
  export const crossOrigin = (request: Request) => {
55
66
  const origin = request.headers.get("origin");
56
- return origin !== null && origin !== new URL(request.url).origin;
67
+ if (origin === null) return false;
68
+ const url = new URL(request.url);
69
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
70
+ if (!host) return origin !== url.origin;
71
+ const scheme = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
72
+ return origin !== `${scheme}://${host}` && origin !== url.origin;
57
73
  };
58
74
 
59
75
  /**