@dashai/cli 0.20.1 → 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 +361 -61
- package/dist/bin.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
{/*
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
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 {
|
|
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
|
|
2150
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
2163
|
-
//
|
|
2164
|
-
//
|
|
2165
|
-
//
|
|
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
|
-
|
|
2169
|
-
|
|
2170
|
-
<
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
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 {
|
|
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
|
|
2209
|
-
*
|
|
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
|
|
2219
|
-
// getServerSession() at render, which needs
|
|
2220
|
-
// prerender during \`next build\` runs with no
|
|
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
|
|
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 `//
|
|
2606
|
-
//
|
|
2607
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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/
|
|
3890
|
-
already have it \u2014 any new session-gated page you add
|
|
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_
|
|
3953
|
-
| POST | \`/api/items\` | dwk_
|
|
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
|
|
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.
|
|
14720
|
+
return "0.21.0";
|
|
14421
14721
|
}
|
|
14422
14722
|
function parseIntOption(value) {
|
|
14423
14723
|
const n = Number.parseInt(value, 10);
|