@dashai/cli 0.20.0 → 0.21.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/bin.js CHANGED
@@ -2068,13 +2068,27 @@ import { AuthProvider, HostRouteSync } from '@dashai/sdk/auth/client';
2068
2068
  import { PlatformProvider, PLATFORM_PREPAINT_SCRIPT } from '@dashai/sdk/platform';
2069
2069
  import { ThemeProvider } from '@/components/theme-provider';
2070
2070
  import { PlatformThemeSync } from '@/components/platform-theme-sync';
2071
+ import { getSession } from '@/lib/session';
2071
2072
 
2072
2073
  export const metadata = {
2073
2074
  title: ${JSON.stringify(opts.name)},
2074
2075
  description: ${JSON.stringify(`Dashwise module: ${opts.slug}`)},
2075
2076
  };
2076
2077
 
2077
- export default function RootLayout({ children }: { children: React.ReactNode }) {
2078
+ // force-dynamic: the layout reads the session at render (below), which needs
2079
+ // DASHWISE_API_URL/KEY. \`next build\` prerenders with NO Dashwise env, so the
2080
+ // SDK would throw "missing apiBaseUrl" \u2192 BUILD_FAILED on first publish.
2081
+ // Required today; becomes optional once the SDK reads cookies before config.
2082
+ export const dynamic = 'force-dynamic';
2083
+
2084
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
2085
+ // Fetch the session ONCE, on the server, and hand it to <AuthProvider> as
2086
+ // \`initialSession\`. Without it the provider fetches /api/auth/session from
2087
+ // the browser after hydration: a second server invocation (and two more
2088
+ // backend calls) on every page load before useSession() knows anything.
2089
+ // \`getSession\` is React-\`cache\`d, so pages calling it in the same request
2090
+ // reuse this result instead of asking the backend again.
2091
+ const session = await getSession();
2078
2092
  return (
2079
2093
  // \`data-dw-surface="embedded"\` is the SERVER default: this module normally
2080
2094
  // renders inside the Dashwise app shell, where the header/nav/theme toggle
@@ -2100,10 +2114,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
2100
2114
  Bridges the SDK to next-themes so the SDK stays theming-library
2101
2115
  agnostic. Inert when standalone \u2014 your own toggle wins there. */}
2102
2116
  <PlatformThemeSync />
2103
- {/* sessionEndpoint points at the same-origin /api/auth/session
2104
- route so the host-scoped session cookie is actually sent \u2014
2105
- a cross-origin fetch to the API would drop it (D8). */}
2106
- <AuthProvider sessionEndpoint="/api/auth/session">
2117
+ {/* initialSession: server-fetched above, so the provider skips
2118
+ its mount-time fetch. sessionEndpoint stays for refresh():
2119
+ it points at the same-origin /api/auth/session route so the
2120
+ host-scoped session cookie is actually sent \u2014 a cross-origin
2121
+ fetch to the API would drop it (D8). */}
2122
+ <AuthProvider sessionEndpoint="/api/auth/session" initialSession={session}>
2107
2123
  {/* Mirrors in-module navigation to the Dashwise host's address
2108
2124
  bar when embedded; inert when standalone. */}
2109
2125
  <HostRouteSync />
@@ -2139,58 +2155,86 @@ function appPageCustomTsx(opts) {
2139
2155
  // \`ModuleShell\` below encodes all of that. Anything that should appear only
2140
2156
  // when the module is opened on its own subdomain goes in \`.dw-standalone-only\`.
2141
2157
  // See CLAUDE.md \u2192 "Embedded-first UI" for the full contract.
2142
- import { getServerSession } from '@dashai/sdk/auth/server';
2158
+ import { db } from '@dashai/generated';
2159
+ import { hasPermissionInSession } from '@dashai/sdk/auth/server';
2143
2160
  import { redirect } from 'next/navigation';
2144
- import { PlusIcon } from 'lucide-react';
2145
- import { Button } from '@/components/ui/button';
2146
2161
  import { ModuleShell } from '@/components/module-shell';
2162
+ import { NewItemForm } from '@/components/new-item-form';
2147
2163
  import { EmptyState } from '@/components/states';
2164
+ import { getSession } from '@/lib/session';
2148
2165
 
2149
- // force-dynamic: this page calls getServerSession() at render, which
2150
- // reads DASHWISE_API_URL/KEY. \`next build\` statically PRERENDERS pages by
2166
+ // force-dynamic: this page reads the session at render, which needs
2167
+ // DASHWISE_API_URL/KEY. \`next build\` statically PRERENDERS pages by
2151
2168
  // default and runs with NO Dashwise env, so the SDK would throw
2152
2169
  // "missing apiBaseUrl" and the platform's first publish would fail with
2153
2170
  // an opaque BUILD_FAILED. Opting into dynamic rendering skips prerender.
2171
+ // Required today; becomes optional once the SDK reads cookies before config.
2154
2172
  export const dynamic = 'force-dynamic';
2155
2173
 
2156
2174
  export default async function Home() {
2157
- const session = await getServerSession();
2175
+ // \`getSession()\` is the per-request cached getServerSession(): the layout
2176
+ // already started it, so awaiting it here costs no extra backend call.
2177
+ const session = await getSession();
2158
2178
  if (!session) {
2159
2179
  redirect('/api/auth/sign-in?return_to=/');
2160
2180
  }
2161
2181
 
2162
- // Replace this with your real first screen: list what the user came to see,
2163
- // and render EmptyState only while there is genuinely nothing yet. Keep the
2164
- // loading state (ListSkeleton) and error state (ErrorState) \u2014 they're in
2165
- // components/states.tsx and they are what makes a module feel finished.
2182
+ // The middleware only checks that a session cookie EXISTS, and the layout
2183
+ // checks no permission \u2014 neither is the guard. What the user may see and
2184
+ // do is a PERMISSION check: here, before any data is read, and again
2185
+ // inside every Server Action (app/actions.ts), because hiding a button is
2186
+ // not authorization. \`items:read\` / \`items:create\` are the per-table
2187
+ // permissions the platform synthesizes when module.json has no \`rbac\`.
2188
+ const canRead = hasPermissionInSession(session, 'items:read');
2189
+ const canCreate = hasPermissionInSession(session, 'items:create');
2190
+
2191
+ // Read only what the user may see. With two or more independent reads,
2192
+ // start them together after the check: \`await Promise.all([a(), b()])\`,
2193
+ // never one await after another.
2194
+ //
2195
+ // \`db.items.list()\` with no \`limit\` returns ONE page of 100 rows and drops
2196
+ // \`total\`. Pass a \`limit\`, use \`listPage()\` when you need the count, or
2197
+ // paginate \u2014 never "read everything and filter in JS".
2198
+ const items = canRead ? await db.items.list({ limit: 50 }) : [];
2199
+
2200
+ // Replace this with your real first screen. Keep the three states: the
2201
+ // loading state is app/loading.tsx (ListSkeleton), errors land in
2202
+ // app/error.tsx, and EmptyState covers "genuinely nothing yet".
2166
2203
  return (
2167
- <ModuleShell
2168
- title="${escapeHtml(opts.name)}"
2169
- primaryAction={
2170
- <Button>
2171
- <PlusIcon aria-hidden="true" />
2172
- Add item
2173
- </Button>
2174
- }
2175
- >
2176
- <EmptyState
2177
- title="Nothing here yet"
2178
- description="This is your module's home. Build the first screen in app/page.tsx \u2014 declare your tables with \`dashwise table create\`, then read them with the typed \`db\` client from @dashai/generated."
2179
- action={
2180
- <Button>
2181
- <PlusIcon aria-hidden="true" />
2182
- Add item
2183
- </Button>
2184
- }
2185
- />
2204
+ <ModuleShell title="${escapeHtml(opts.name)}">
2205
+ {canCreate ? <NewItemForm /> : null}
2206
+ {!canRead ? (
2207
+ <EmptyState
2208
+ title="You can't see items yet"
2209
+ description="Your role in this module doesn't include viewing items. Ask a workspace admin for access."
2210
+ />
2211
+ ) : items.length === 0 ? (
2212
+ <EmptyState
2213
+ title="Nothing here yet"
2214
+ description="This is your module's home. Build the first screen in app/page.tsx \u2014 declare your tables with \`dashwise table create\`, then read them with the typed \`db\` client from @dashai/generated."
2215
+ />
2216
+ ) : (
2217
+ <ul className="divide-y divide-border rounded-lg border border-border">
2218
+ {items.map((item) => (
2219
+ <li
2220
+ key={item.id}
2221
+ className="flex items-center justify-between gap-4 px-4 py-3 text-sm"
2222
+ >
2223
+ <span className="min-w-0 truncate">{item.title}</span>
2224
+ {item.done ? (
2225
+ <span className="shrink-0 text-muted-foreground">Done</span>
2226
+ ) : null}
2227
+ </li>
2228
+ ))}
2229
+ </ul>
2230
+ )}
2186
2231
  </ModuleShell>
2187
2232
  );
2188
2233
  }
2189
2234
  `;
2190
2235
  }
2191
2236
  function appDashboardPageTsx() {
2192
- return `import { getServerSession } from '@dashai/sdk/auth/server';
2193
- import { redirect } from 'next/navigation';
2237
+ return `import { redirect } from 'next/navigation';
2194
2238
  import { LogOut, ShieldCheck } from 'lucide-react';
2195
2239
  import { Button } from '@/components/ui/button';
2196
2240
  import { ModuleShell } from '@/components/module-shell';
@@ -2201,12 +2245,14 @@ import {
2201
2245
  CardHeader,
2202
2246
  CardTitle,
2203
2247
  } from '@/components/ui/card';
2248
+ import { getSession } from '@/lib/session';
2204
2249
 
2205
2250
  /**
2206
2251
  * An ADDITIONAL example route \u2014 your real home is app/page.tsx (\`/\`).
2207
2252
  * This \`/dashboard\` page shows a second auth-gated Server Component: keep
2208
- * it, rename it, or delete it. The middleware ensures the user has a session
2209
- * cookie before this page renders; \`getServerSession()\` validates the cookie
2253
+ * it, rename it, or delete it. The middleware only ensures a session cookie
2254
+ * EXISTS before this page renders; \`getSession()\` (lib/session.ts, a
2255
+ * per-request cached \`getServerSession()\`) validates the cookie
2210
2256
  * against the backend's \`/api/auth/sessions/me\` endpoint, returning the
2211
2257
  * decoded \`Session\` (or \`null\` if stale).
2212
2258
  *
@@ -2215,13 +2261,15 @@ import {
2215
2261
  * Dashwise app the user menu already offers sign-out, so rendering our own
2216
2262
  * would duplicate the app's chrome. See CLAUDE.md \u2192 "Embedded-first UI".
2217
2263
  */
2218
- // force-dynamic: same reason as app/page.tsx \u2014 this route calls
2219
- // getServerSession() at render, which needs DASHWISE_API_URL/KEY. Static
2220
- // prerender during \`next build\` runs with no env \u2192 BUILD_FAILED. Opt out.
2264
+ // force-dynamic: same reason as app/page.tsx \u2014 this route reads the session
2265
+ // (getSession() \u2192 getServerSession()) at render, which needs
2266
+ // DASHWISE_API_URL/KEY. Static prerender during \`next build\` runs with no
2267
+ // env \u2192 BUILD_FAILED. Opt out.
2268
+ // Required today; becomes optional once the SDK reads cookies before config.
2221
2269
  export const dynamic = 'force-dynamic';
2222
2270
 
2223
2271
  export default async function Dashboard() {
2224
- const session = await getServerSession();
2272
+ const session = await getSession();
2225
2273
  if (!session) {
2226
2274
  redirect('/api/auth/sign-in?return_to=/');
2227
2275
  }
@@ -2602,26 +2650,44 @@ export async function POST(request: Request) {
2602
2650
  `;
2603
2651
  }
2604
2652
  function appApiItemsRouteTs() {
2605
- return `// Example data-plane route \u2014 lists + creates rows in the seeded
2606
- // \`items\` table via the generated typed SDK. Delete this (and the
2607
- // \`items\` table in module.json) once you add your own tables.
2653
+ return `// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2654
+ // EXAMPLE ROUTE \u2014 DELETE OR RENAME IT.
2655
+ // It exists to show a guarded JSON route over the seeded \`items\` table.
2656
+ // Delete this file (and the \`items\` table in module.json) once you add
2657
+ // your own tables; your UI's writes belong in Server Actions
2658
+ // (app/actions.ts in the full scaffold), not here.
2659
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2660
+ //
2661
+ // Every route handler checks a PERMISSION. The middleware only checks that a
2662
+ // session cookie exists, and \`db\` talks to the Data Hub with the module's own
2663
+ // dwk_ key \u2014 so an unguarded route hands the module's full data access to
2664
+ // anyone who can reach the URL. \`withPermission\` answers 401 with no session
2665
+ // and 403 without the permission. \`items:read\` / \`items:create\` are the
2666
+ // per-table permissions the platform synthesizes when module.json has no
2667
+ // \`rbac\` block. Per-user rows also need an ownership check in the handler.
2668
+ //
2669
+ // A module scaffolded with \`--simple\` has no sign-in routes, so this answers
2670
+ // 401 until you add sign-in \u2014 that is the intended default.
2608
2671
  //
2609
2672
  // \`db\` comes from @dashai/generated, regenerated on every module.json
2610
2673
  // save by \`dashwise dev\`. Run it (or \`dashwise module generate\`) first.
2611
2674
  import { NextResponse } from 'next/server';
2675
+ import { withPermission } from '@dashai/sdk/auth/middleware';
2612
2676
  import { db } from '@dashai/generated';
2613
2677
 
2614
- export async function GET() {
2678
+ export const GET = withPermission('items:read')(async () => {
2615
2679
  // \`db.<table>.list()\` resolves to the rows array itself \u2014 the typed
2616
2680
  // client unwraps the \`{ rows, total, ... }\` page envelope (see the
2617
2681
  // generated \`client/tables.d.ts\`: \`list(): Promise<ItemsRow[]>\`).
2618
2682
  // Don't reach for \`.rows\` here: it's \`undefined\` on an array, and
2619
2683
  // \`NextResponse.json(undefined)\` throws "Value is not JSON serializable".
2684
+ // Without a \`limit\` it returns ONE page of 100; use \`listPage()\` for
2685
+ // the total, or paginate.
2620
2686
  const rows = await db.items.list({ limit: 50 });
2621
2687
  return NextResponse.json(rows);
2622
- }
2688
+ });
2623
2689
 
2624
- export async function POST(request: Request) {
2690
+ export const POST = withPermission('items:create')(async (request) => {
2625
2691
  const body = (await request.json().catch(() => null)) as
2626
2692
  | { title?: unknown; done?: unknown }
2627
2693
  | null;
@@ -2635,7 +2701,7 @@ export async function POST(request: Request) {
2635
2701
  const done = typeof body?.done === 'boolean' ? body.done : false;
2636
2702
  const row = await db.items.create({ title, done });
2637
2703
  return NextResponse.json(row, { status: 201 });
2638
- }
2704
+ });
2639
2705
  `;
2640
2706
  }
2641
2707
  function appApiAuthSessionTs() {
@@ -3471,6 +3537,151 @@ export default function Error({
3471
3537
  }
3472
3538
  `;
3473
3539
  }
3540
+ function libSessionTs() {
3541
+ return `import { cache } from 'react';
3542
+ import { getServerSession } from '@dashai/sdk/auth/server';
3543
+
3544
+ /**
3545
+ * The current session (or \`null\`), fetched at most ONCE per request.
3546
+ *
3547
+ * \`getServerSession()\` is uncached and costs two backend round trips
3548
+ * (session + RBAC). The root layout reads it to hydrate <AuthProvider>, and
3549
+ * pages read it for their guard; React \`cache()\` makes the second caller
3550
+ * reuse the first one's result within the same server render.
3551
+ *
3552
+ * Use this in layouts, pages and Server Components. Server Actions and route
3553
+ * handlers run in their own request: guard those with
3554
+ * \`requirePermission()\` / \`withPermission()\` instead.
3555
+ */
3556
+ export const getSession = cache(() => getServerSession());
3557
+ `;
3558
+ }
3559
+ function appLoadingTsx() {
3560
+ return `// Root loading state. Next.js shows this IMMEDIATELY on navigation while the
3561
+ // next page renders on the server (session + data reads), so the user gets
3562
+ // feedback within 100 ms instead of a frozen screen. Shaped like the content
3563
+ // that is coming: match \`rows\` and the row height to your real list, or it
3564
+ // visibly jumps on load. Add a loading.tsx per route segment when a screen's
3565
+ // shape differs from a list.
3566
+ import { ListSkeleton } from '@/components/states';
3567
+
3568
+ export default function Loading() {
3569
+ return (
3570
+ <div className="mx-auto w-full max-w-6xl p-4 md:p-6">
3571
+ <ListSkeleton />
3572
+ </div>
3573
+ );
3574
+ }
3575
+ `;
3576
+ }
3577
+ function appActionsTs() {
3578
+ return `'use server';
3579
+
3580
+ // Server Actions \u2014 the canonical WRITE path for this module.
3581
+ //
3582
+ // Every action follows the same four steps, in this order:
3583
+ // 1. Check a PERMISSION. The middleware only checks that a session cookie
3584
+ // exists; it says nothing about what the user may do. A Server Action is
3585
+ // a public POST endpoint \u2014 anyone with a cookie can call it directly,
3586
+ // whatever your UI shows or hides.
3587
+ // 2. Validate the input (FormData is untrusted).
3588
+ // 3. Write through the generated \`db\` client (the module's server-side
3589
+ // dwk_ key \u2014 never send that key to the browser).
3590
+ // 4. Revalidate ONE narrow path (or tag) \u2014 the screen that shows the data.
3591
+ // Never call router.refresh() after it: revalidatePath already sends
3592
+ // the fresh render back with the action's response, and a refresh
3593
+ // re-renders everything a second time.
3594
+ //
3595
+ // Per-user data (rows that belong to one person) also needs an OWNERSHIP
3596
+ // check: store \`session.user.id\` on create, and on update/delete compare it
3597
+ // before writing. The dwk_ key is the module's ceiling, not a per-user rule.
3598
+ import { revalidatePath } from 'next/cache';
3599
+ import { requirePermission } from '@dashai/sdk/auth/server';
3600
+ import { db } from '@dashai/generated';
3601
+
3602
+ export type CreateItemState = { error: string } | null;
3603
+
3604
+ export async function createItem(
3605
+ _prev: CreateItemState,
3606
+ formData: FormData,
3607
+ ): Promise<CreateItemState> {
3608
+ // 1. Throws a DashwiseError (401 no session / 403 denied) \u2192 app/error.tsx.
3609
+ // The page only renders the form for users who hold the permission, so
3610
+ // reaching this throw means someone called the action directly.
3611
+ await requirePermission('items:create');
3612
+
3613
+ // 2. Validate.
3614
+ const raw = formData.get('title');
3615
+ const title = typeof raw === 'string' ? raw.trim() : '';
3616
+ if (!title) {
3617
+ return { error: 'Give the item a title.' };
3618
+ }
3619
+
3620
+ // 3. Write.
3621
+ await db.items.create({ title, done: false });
3622
+
3623
+ // 4. One narrow revalidate: only the home page lists items.
3624
+ revalidatePath('/');
3625
+ return null;
3626
+ }
3627
+ `;
3628
+ }
3629
+ function componentsNewItemFormTsx() {
3630
+ return `"use client";
3631
+
3632
+ // The client half of the canonical write path (see app/actions.ts).
3633
+ //
3634
+ // Feedback within 100 ms: the submit button reads \`useFormStatus()\` and
3635
+ // switches to a pending state the moment the form is submitted, while the
3636
+ // Server Action runs. For lists where the new row should appear instantly,
3637
+ // layer \`useOptimistic\` on top \u2014 it is DISPLAY-ONLY: the server still checks
3638
+ // the permission and is the source of truth when the action returns.
3639
+ import { useActionState } from "react";
3640
+ import { useFormStatus } from "react-dom";
3641
+ import { PlusIcon } from "lucide-react";
3642
+ import { Button } from "@/components/ui/button";
3643
+ import { createItem, type CreateItemState } from "@/app/actions";
3644
+
3645
+ export function NewItemForm() {
3646
+ const [state, formAction] = useActionState<CreateItemState, FormData>(
3647
+ createItem,
3648
+ null,
3649
+ );
3650
+ return (
3651
+ <form action={formAction} className="flex flex-wrap items-start gap-2">
3652
+ <label htmlFor="new-item-title" className="sr-only">
3653
+ Title
3654
+ </label>
3655
+ <input
3656
+ id="new-item-title"
3657
+ name="title"
3658
+ required
3659
+ maxLength={200}
3660
+ autoComplete="off"
3661
+ placeholder="New item"
3662
+ className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
3663
+ />
3664
+ <SubmitButton />
3665
+ {state?.error ? (
3666
+ <p role="alert" className="w-full text-sm text-destructive">
3667
+ {state.error}
3668
+ </p>
3669
+ ) : null}
3670
+ </form>
3671
+ );
3672
+ }
3673
+
3674
+ function SubmitButton() {
3675
+ const { pending } = useFormStatus();
3676
+ return (
3677
+ <Button type="submit" disabled={pending}>
3678
+ <PlusIcon aria-hidden="true" />
3679
+ {pending ? "Adding\u2026" : "Add item"}
3680
+ </Button>
3681
+ );
3682
+ }
3683
+ `;
3684
+ }
3474
3685
  function seedMjs() {
3475
3686
  return `/**
3476
3687
  * seed.mjs \u2014 OPTIONAL seed/maintenance script template.
@@ -3801,16 +4012,31 @@ Import from there:
3801
4012
  \`\`\`ts
3802
4013
  import { db, qb } from '@dashai/generated';
3803
4014
 
3804
- // Simple CRUD
3805
- const rows = await db.<table>.list();
4015
+ // Simple CRUD. list() with no \`limit\` returns ONE page of 100 rows and
4016
+ // drops \`total\` \u2014 pass a \`limit\`, use listPage() for the total, or paginate.
4017
+ const rows = await db.<table>.list({ limit: 50 });
4018
+ const page = await db.<table>.listPage({ limit: 50, offset: 0 }); // { rows, total, ... }
3806
4019
  await db.<table>.create({ /* fields */ });
3807
4020
 
3808
4021
  // Query builder \u2014 a per-table getter (NOT a \`.from()\` call); \`.where()\`
3809
4022
  // takes a single clause object (field \u2192 value or field \u2192 operator), and
3810
4023
  // \`.execute()\` runs it. Chain \`.orderBy()\` / \`.limit()\` / \`.count()\` etc.
3811
4024
  const open = await qb.<table>.where({ status: { equal: 'open' } }).execute();
4025
+
4026
+ // Aggregate ON THE SERVER \u2014 one call, not one count() per option.
4027
+ const openCount = await qb.<table>.where({ status: { equal: 'open' } }).count();
4028
+ const byStatus = await qb.<table>
4029
+ .groupBy(['status'])
4030
+ .agg({ total: { count: '*' } })
4031
+ .execute(); // byStatus.rows = [{ status: 'open', total: 7 }, ...]
3812
4032
  \`\`\`
3813
4033
 
4034
+ Children of a linked parent: \`link_row\` fields take no value filters in a
4035
+ bare \`where\`, so join through the link and filter the joined side \u2014
4036
+ \`qb.comments.joinTask().where({ 'task.id': taskId }).execute()\` \u2014 which runs
4037
+ in SQL. Never read the whole child table and filter it in JS: \`list()\` stops
4038
+ at 100 rows, so that pattern is slow AND silently wrong past the first page.
4039
+
3814
4040
  The data plane authenticates via the per-module scoped \`dwk_\` API key
3815
4041
  cached in \`~/.config/dashwise/auth.json#modules[${opts.slug}]\` after
3816
4042
  \`dashwise init\` provisions the dev install. \`dashwise dev\` injects it
@@ -3850,10 +4076,67 @@ follows the returned \`redirect_url\` \u2014 the same code runs in dev and in
3850
4076
  production, with no installation id or dev-session id in the request.
3851
4077
 
3852
4078
  In Server Components: \`await getServerSession()\` (from
3853
- \`@dashai/sdk/auth/server\`).
4079
+ \`@dashai/sdk/auth/server\`)${isCustom ? ` \u2014 or the per-request cached
4080
+ \`getSession()\` from \`lib/session.ts\`, which the root layout already uses` : ""}.
3854
4081
  In Client Components: \`useSession()\` (from
3855
- \`@dashai/sdk/auth/client\`), wrapped in \`<AuthProvider>\` (already
3856
- in \`app/layout.tsx\`).
4082
+ \`@dashai/sdk/auth/client\`), wrapped in \`<AuthProvider>\`${isCustom ? ` (already
4083
+ in \`app/layout.tsx\`, hydrated server-side through \`initialSession\`)` : ""}.
4084
+
4085
+ ## Authorization \u2014 every action and route checks a permission
4086
+
4087
+ Signing in is wired; **authorization is not automatic.** The middleware only
4088
+ checks that a session cookie EXISTS \u2014 it never checks what the user may do.
4089
+ And \`db\` / \`qb\` run with the module's \`dwk_\` key, which can touch every
4090
+ table the module owns. So:
4091
+
4092
+ - **Every Server Action** starts with \`await requirePermission('<table>:<op>')\`
4093
+ (from \`@dashai/sdk/auth/server\`; throws 401/403). A Server Action is a
4094
+ public POST endpoint \u2014 hiding the button does not protect it.
4095
+ - **Every route handler** is wrapped: \`export const GET =
4096
+ withPermission('<table>:read')(async (req) => \u2026)\` (from
4097
+ \`@dashai/sdk/auth/middleware\`; answers 401/403).
4098
+ - **Per-user data needs an ownership check.** Store \`session.user.id\` on the
4099
+ row and compare it before every update/delete. A permission says the user
4100
+ may edit *items*, not *this* item.
4101
+ - **Optimistic UI is display-only.** \`useOptimistic\` changes what the user
4102
+ sees; the server action still checks the permission and its result wins.
4103
+ - Shape the UI with \`hasPermissionInSession(session, key)\` (server) or
4104
+ \`useHasPermission(key)\` (client) so nobody sees a button that then 403s.
4105
+
4106
+ With no \`rbac\` block in \`module.json\` the platform synthesizes
4107
+ \`<table>:create|read|update|delete\` permissions plus admin/editor/viewer
4108
+ roles.${isCustom ? ` \`app/actions.ts\` and \`app/api/items/route.ts\` show both guards.` : ""}
4109
+
4110
+ ## Responsiveness \u2014 rules, not polish
4111
+
4112
+ A module runs behind the platform proxy, so every server round trip is
4113
+ expensive. These are the defaults; break one only with a reason in the code.
4114
+
4115
+ - **Feedback within 100 ms.** Every click that writes shows a pending state
4116
+ immediately: \`useFormStatus()\` in the submit button, or \`useOptimistic\`
4117
+ for lists${isCustom ? ` (\`components/new-item-form.tsx\` shows the pattern)` : ""}.
4118
+ Navigation shows \`loading.tsx\`${isCustom ? ` (\`app/loading.tsx\` renders \`ListSkeleton\`)` : ""}.
4119
+ - **One narrow revalidate per write.** After a write, call ONE
4120
+ \`revalidatePath('/the/screen')\` or \`revalidateTag(tag)\` \u2014 the screen
4121
+ that shows the data, not \`'/'\` with \`'layout'\`. **Never call
4122
+ \`router.refresh()\` after it**: the action's response already carries the
4123
+ fresh render, and a refresh renders everything a second time.
4124
+ - **Parallel reads.** Independent awaits go in \`Promise.all([...])\`, never
4125
+ one after another. Start reads that don't need the session in parallel
4126
+ with the session lookup, but check the permission before rendering or
4127
+ returning any data. Never rely on the middleware or the layout as the
4128
+ guard: the middleware only checks that a cookie exists and the layout
4129
+ checks no permission.
4130
+ - **No writes during a GET render.** Pages and layouts only read. Writes go
4131
+ in Server Actions or POST routes; "touch lastSeen on render" style writes
4132
+ make every view slow and break caching.
4133
+ - **\`prefetch={false}\`** on \`<Link>\`s in long row or card lists \u2014 Next
4134
+ otherwise prefetches every visible row's page.
4135
+ - **Aggregate on the server.** \`qb.<table>.groupBy([...]).agg({...})\` or
4136
+ \`.count()\` \u2014 never N \`count()\` calls in a loop, and never "list
4137
+ everything and count in JS".
4138
+ - **\`list()\` returns one page of 100** and hides \`total\`. Pass a \`limit\`,
4139
+ use \`listPage()\` when you need the total, or paginate.
3857
4140
  ${isCustom ? `
3858
4141
  ## Design & home
3859
4142
 
@@ -3886,8 +4169,10 @@ reads the session or data at render (calls \`getServerSession()\` or a
3886
4169
  \`dwk_\`-authenticated data call). Without it, \`next build\` statically
3887
4170
  prerenders the page with NO Dashwise env vars, the SDK throws
3888
4171
  \`missing apiBaseUrl\`, and the platform's first publish fails with an
3889
- opaque \`BUILD_FAILED\`. The scaffolded \`app/page.tsx\` + \`app/dashboard/page.tsx\`
3890
- already have it \u2014 any new session-gated page you add needs it too.
4172
+ opaque \`BUILD_FAILED\`. The scaffolded \`app/layout.tsx\`, \`app/page.tsx\` and
4173
+ \`app/dashboard/page.tsx\` already have it \u2014 any new session-gated page you add
4174
+ needs it too. Required today; becomes optional once the SDK reads cookies
4175
+ before config.
3891
4176
 
3892
4177
  A passing local \`npm run build\` is NOT proof: your \`.env.local\` supplies
3893
4178
  the env the platform build won't have, so it masks the failure. \`GET\` route
@@ -3949,8 +4234,12 @@ what tables it owns.
3949
4234
 
3950
4235
  | Method | Path | Auth | Purpose |
3951
4236
  |---|---|---|---|
3952
- | GET | \`/api/items\` | dwk_ (server-side) | list \`items\` rows via \`db.items.list()\` |
3953
- | POST | \`/api/items\` | dwk_ (server-side) | create an \`items\` row via \`db.items.create()\` |
4237
+ | GET | \`/api/items\` | session + \`items:read\` (\`withPermission\`); data via dwk_ server-side | EXAMPLE \u2014 delete or rename. List \`items\` rows via \`db.items.list({ limit: 50 })\` |
4238
+ | POST | \`/api/items\` | session + \`items:create\` (\`withPermission\`); data via dwk_ server-side | EXAMPLE \u2014 delete or rename. Create an \`items\` row via \`db.items.create()\` |
4239
+
4240
+ Every route and Server Action checks a permission; the middleware only checks
4241
+ that a session cookie exists. List each Server Action here too, with its
4242
+ permission (custom scaffold: \`createItem\` in \`app/actions.ts\`, \`items:create\`).
3954
4243
 
3955
4244
  ## Business rules
3956
4245
 
@@ -4211,8 +4500,8 @@ async function moduleInitCommand(slugArg, options) {
4211
4500
  ["spec.md", specMd(scaffoldOpts)],
4212
4501
  ["agent-log.md", agentLogMd(scaffoldOpts)],
4213
4502
  // Example data-plane route (both flavors) — exercises the generated
4214
- // SDK against the seeded `items` table. Ships for simple + custom
4215
- // since both produce `@dashai/generated`.
4503
+ // SDK against the seeded `items` table, guarded by withPermission.
4504
+ // Ships for simple + custom since both produce `@dashai/generated`.
4216
4505
  ["app/api/items/route.ts", appApiItemsRouteTs()],
4217
4506
  // C12/D5 (Wave 2) — commented-out seed-script template. Pairs with
4218
4507
  // `dashwise run -- node seed.mjs`; carries the publish-packs-
@@ -4228,6 +4517,9 @@ async function moduleInitCommand(slugArg, options) {
4228
4517
  ["app/globals.css", globalsCss()],
4229
4518
  ["lib/utils.ts", libUtilsTs()],
4230
4519
  ["lib/request-origin.ts", libRequestOriginTs()],
4520
+ // Per-request cached getServerSession(): the layout (initialSession)
4521
+ // and the pages share one session fetch instead of two.
4522
+ ["lib/session.ts", libSessionTs()],
4231
4523
  ["components/theme-provider.tsx", componentsThemeProviderTsx()],
4232
4524
  // Embedded-first defaults: follow the Dashwise app's theme, and ship the
4233
4525
  // page frame + the empty/loading/error states that a module needs to
@@ -4244,6 +4536,14 @@ async function moduleInitCommand(slugArg, options) {
4244
4536
  // reset() retry, semantic tokens (which resolve now that
4245
4537
  // globals.css maps them in @theme).
4246
4538
  ["app/error.tsx", appErrorTsx()],
4539
+ // Root loading state: renders ListSkeleton instantly on navigation
4540
+ // while the next page's server render is in flight.
4541
+ ["app/loading.tsx", appLoadingTsx()],
4542
+ // The canonical write path: a permission-guarded Server Action with
4543
+ // one narrow revalidatePath, and its form with a useFormStatus
4544
+ // pending state.
4545
+ ["app/actions.ts", appActionsTs()],
4546
+ ["components/new-item-form.tsx", componentsNewItemFormTsx()],
4247
4547
  ["app/dashboard/page.tsx", appDashboardPageTsx()],
4248
4548
  ["app/sign-in-error/page.tsx", appSignInErrorPageTsx()],
4249
4549
  ["app/api/auth/sign-in/route.ts", appApiAuthSignInTs()],
@@ -14417,7 +14717,7 @@ fieldCmd.command("set-type <table-slug> <field-slug> <new-type>").description(
14417
14717
  }
14418
14718
  );
14419
14719
  function getVersion() {
14420
- return "0.20.0";
14720
+ return "0.21.0";
14421
14721
  }
14422
14722
  function parseIntOption(value) {
14423
14723
  const n = Number.parseInt(value, 10);