elm-ssr 1.0.0 → 1.0.3

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/lib/scaffold.mjs CHANGED
@@ -48,10 +48,10 @@ const elmJsonTemplate = () => ({
48
48
  }
49
49
  });
50
50
 
51
- const sharedTemplate = (namespace) => `module ${namespace}.View.Shared exposing (head, shell)
51
+ const sharedTemplate = (namespace, auth) => `module ${namespace}.View.Shared exposing (head, layout)
52
52
 
53
- import ElmSsr.Html exposing (Node, div, h1, text)
54
- import ElmSsr.Html.Attributes exposing (class)
53
+ import ElmSsr.Html exposing (Node, a, div, header, main_, nav, span, text)
54
+ import ElmSsr.Html.Attributes exposing (class, href)
55
55
  import ElmSsr.Page as Page
56
56
 
57
57
 
@@ -63,19 +63,33 @@ head =
63
63
  ]
64
64
 
65
65
 
66
- shell : String -> List (Node msg) -> Node msg
67
- shell heading body =
68
- div [ class "shell" ] (h1 [] [ text heading ] :: body)
66
+ layout : String -> List (Node msg) -> Node msg
67
+ layout pageTitle body =
68
+ div [ class "page" ]
69
+ [ header [ class "header" ]
70
+ [ div [ class "header-inner" ]
71
+ [ a [ class "brand", href "/" ]
72
+ [ span [ class "brand-icon" ] [ text "◆" ]
73
+ , text "elm-ssr"
74
+ ]
75
+ , nav [ class "nav" ]
76
+ [ a [ class "nav-link", href "/" ] [ text "Home" ]
77
+ , a [ class "nav-link", href "/counter" ] [ text "Counter" ]${auth ? `
78
+ , a [ class "nav-link", href "/login" ] [ text "Sign in" ]` : ""}
79
+ ]
80
+ ]
81
+ ]
82
+ , main_ [ class "main" ]
83
+ [ div [ class "container" ] body
84
+ ]
85
+ ]
69
86
  `;
70
87
 
71
- const indexRouteTemplate = (namespace) => `module ${namespace}.Routes.Index exposing (page, action)
72
-
73
- -- File-based routing: this module maps to GET /.
74
- -- A page is stateless (no Model/Msg) and ships no client JavaScript.
88
+ const indexRouteTemplate = (namespace, auth) => `module ${namespace}.Routes.Index exposing (page, action)
75
89
 
76
90
  import ElmSsr.Action as Action exposing (Action)
77
91
  import ElmSsr.Document exposing (Document)
78
- import ElmSsr.Html exposing (a, p, text)
92
+ import ElmSsr.Html exposing (a, div, h1, h2, p, section, span, text)
79
93
  import ElmSsr.Html.Attributes exposing (class, href)
80
94
  import ElmSsr.Loader as Loader exposing (Loader)
81
95
  import ElmSsr.Page as Page
@@ -85,10 +99,7 @@ import ${namespace}.View.Shared as Shared
85
99
 
86
100
  page : Request -> Loader (Document Never)
87
101
  page _ =
88
- Loader.env "GREETING"
89
- |> Loader.map (\\maybeGreeting ->
90
- view (Maybe.withDefault "Hello from default env!" maybeGreeting)
91
- )
102
+ Loader.succeed view
92
103
 
93
104
 
94
105
  action : Request -> Action (Document Never)
@@ -96,30 +107,47 @@ action _ =
96
107
  Action.fail 405 "Method not allowed"
97
108
 
98
109
 
99
- view : String -> Document Never
100
- view greeting =
110
+ view : Document Never
111
+ view =
101
112
  Page.page
102
- { title = "Starter | elm-ssr"
113
+ { title = "elm-ssr"
103
114
  , head = Shared.head
104
115
  , body =
105
- [ Shared.shell "elm-ssr starter"
106
- [ p [] [ text ("Greeting from environment: " ++ greeting) ]
107
- , p [] [ text "This page is stateless and renders on the edge with no client runtime." ]
108
- , p [] [ a [ class "link", href "/counter" ] [ text "Open the interactive counter" ] ]
116
+ [ Shared.layout "Home"
117
+ [ section [ class "hero" ]
118
+ [ h1 [ class "hero-title" ] [ text "Ship fast." ]
119
+ , p [ class "hero-subtitle" ]
120
+ [ text "Type-safe server-side rendering with interactive islands. Runs on Cloudflare Workers and Bun." ]
121
+ , div [ class "hero-actions" ]
122
+ [ a [ class "btn btn-primary", href "/counter" ] [ text "Try the counter" ]${auth ? `
123
+ , a [ class "btn btn-secondary", href "/login" ] [ text "Sign in" ]` : ""}
124
+ ]
125
+ ]
126
+ , section [ class "features" ]
127
+ [ featureCard "⚡" "Edge-first" "Renders in milliseconds at the edge. No cold starts."
128
+ , featureCard "🦺" "Fully typed" "End-to-end Elm types from DB to HTML. No runtime surprises."
129
+ , featureCard "🏝️" "Islands" "Add interactivity exactly where you need it. Zero JS elsewhere."
130
+ ]
109
131
  ]
110
132
  ]
111
133
  }
134
+
135
+
136
+ featureCard : String -> String -> String -> ElmSsr.Html.Node msg
137
+ featureCard icon title_ body =
138
+ div [ class "feature-card" ]
139
+ [ span [ class "feature-icon" ] [ text icon ]
140
+ , h2 [ class "feature-title" ] [ text title_ ]
141
+ , p [ class "feature-body" ] [ text body ]
142
+ ]
112
143
  `;
113
144
 
114
145
  const counterRouteTemplate = (namespace) => `module ${namespace}.Routes.Counter exposing (page, action)
115
146
 
116
- -- File-based routing: GET /counter. A static page that embeds an interactive
117
- -- island. The page ships no client runtime; the browser mounts the island
118
- -- separately, and only that root updates.
119
-
120
147
  import ElmSsr.Action as Action exposing (Action)
121
148
  import ElmSsr.Document exposing (Document)
122
- import ElmSsr.Html exposing (p, text)
149
+ import ElmSsr.Html exposing (div, h1, p, text)
150
+ import ElmSsr.Html.Attributes exposing (class)
123
151
  import ElmSsr.Loader as Loader exposing (Loader)
124
152
  import ElmSsr.Page as Page
125
153
  import ElmSsr.Route exposing (Request)
@@ -143,9 +171,14 @@ view =
143
171
  { title = "Counter | elm-ssr"
144
172
  , head = Shared.head
145
173
  , body =
146
- [ Shared.shell "Counter"
147
- [ p [] [ text "This page is static. Only the counter below is an interactive island." ]
148
- , Counter.embed { start = 0 }
174
+ [ Shared.layout "Counter"
175
+ [ div [ class "page-header" ]
176
+ [ h1 [] [ text "Interactive Counter" ]
177
+ , p [ class "page-subtitle" ]
178
+ [ text "This page is server-rendered. Only the counter widget below is a client-side island — zero JS elsewhere." ]
179
+ ]
180
+ , div [ class "card" ]
181
+ [ Counter.embed { start = 0 } ]
149
182
  ]
150
183
  ]
151
184
  }
@@ -238,19 +271,18 @@ subscriptions _ =
238
271
  view : Model -> Html Msg
239
272
  view model =
240
273
  div [ class "counter" ]
241
- [ button [ class "button", type_ "button", onClick Decrement ] [ text "-" ]
242
- , span [ class "value" ] [ text (String.fromInt model.count) ]
243
- , button [ class "button primary", type_ "button", onClick Increment ] [ text "+" ]
274
+ [ button [ class "btn btn-secondary btn-square", type_ "button", onClick Decrement ] [ text "" ]
275
+ , span [ class "counter-value" ] [ text (String.fromInt model.count) ]
276
+ , button [ class "btn btn-primary btn-square", type_ "button", onClick Increment ] [ text "+" ]
244
277
  ]
245
278
  `;
246
279
 
247
280
  const notFoundRouteTemplate = (namespace) => `module ${namespace}.Routes.NotFound exposing (page, action)
248
281
 
249
- -- File-based routing: NotFound is the fallback when no other route matches.
250
-
251
282
  import ElmSsr.Action as Action exposing (Action)
252
283
  import ElmSsr.Document exposing (Document)
253
- import ElmSsr.Html exposing (p, text)
284
+ import ElmSsr.Html exposing (a, div, h1, p, text)
285
+ import ElmSsr.Html.Attributes exposing (class, href)
254
286
  import ElmSsr.Loader as Loader exposing (Loader)
255
287
  import ElmSsr.Page as Page
256
288
  import ElmSsr.Route exposing (Request)
@@ -272,44 +304,124 @@ view =
272
304
  Page.notFound
273
305
  { title = "Not Found | elm-ssr"
274
306
  , head = Shared.head
275
- , body = [ Shared.shell "404" [ p [] [ text "This route does not exist." ] ] ]
307
+ , body =
308
+ [ Shared.layout "Not Found"
309
+ [ div [ class "error-page" ]
310
+ [ h1 [ class "error-code" ] [ text "404" ]
311
+ , p [ class "error-message" ] [ text "This page doesn't exist." ]
312
+ , a [ class "btn btn-primary", href "/" ] [ text "Go home" ]
313
+ ]
314
+ ]
315
+ ]
276
316
  }
277
317
  `;
278
318
 
319
+ const betterAuthMigrationTemplate = () => `-- BetterAuth requires these 4 tables. Do not rename or remove columns.
320
+ -- Generated by elm-ssr. See https://www.better-auth.com/docs/concepts/database
321
+ CREATE TABLE IF NOT EXISTS "user" (
322
+ id TEXT NOT NULL PRIMARY KEY,
323
+ name TEXT NOT NULL,
324
+ email TEXT NOT NULL UNIQUE,
325
+ "emailVerified" INTEGER NOT NULL DEFAULT 0,
326
+ image TEXT,
327
+ "createdAt" INTEGER NOT NULL,
328
+ "updatedAt" INTEGER NOT NULL
329
+ );
330
+
331
+ CREATE TABLE IF NOT EXISTS session (
332
+ id TEXT NOT NULL PRIMARY KEY,
333
+ "expiresAt" INTEGER NOT NULL,
334
+ token TEXT NOT NULL UNIQUE,
335
+ "createdAt" INTEGER NOT NULL,
336
+ "updatedAt" INTEGER NOT NULL,
337
+ "ipAddress" TEXT,
338
+ "userAgent" TEXT,
339
+ "userId" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE
340
+ );
341
+
342
+ CREATE TABLE IF NOT EXISTS account (
343
+ id TEXT NOT NULL PRIMARY KEY,
344
+ "accountId" TEXT NOT NULL,
345
+ "providerId" TEXT NOT NULL,
346
+ "userId" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
347
+ "accessToken" TEXT,
348
+ "refreshToken" TEXT,
349
+ "idToken" TEXT,
350
+ "accessTokenExpiresAt" INTEGER,
351
+ "refreshTokenExpiresAt" INTEGER,
352
+ scope TEXT,
353
+ password TEXT,
354
+ "createdAt" INTEGER NOT NULL,
355
+ "updatedAt" INTEGER NOT NULL
356
+ );
357
+
358
+ CREATE TABLE IF NOT EXISTS verification (
359
+ id TEXT NOT NULL PRIMARY KEY,
360
+ identifier TEXT NOT NULL,
361
+ value TEXT NOT NULL,
362
+ "expiresAt" INTEGER NOT NULL,
363
+ "createdAt" INTEGER,
364
+ "updatedAt" INTEGER
365
+ );
366
+ `;
367
+
368
+ const auth0MigrationTemplate = () => `-- Local user cache for Auth0 authenticated users.
369
+ -- Auth0 is the canonical identity source; this table stores profile data after first login.
370
+ CREATE TABLE IF NOT EXISTS users (
371
+ id TEXT NOT NULL PRIMARY KEY,
372
+ email TEXT NOT NULL UNIQUE,
373
+ name TEXT,
374
+ picture TEXT,
375
+ "createdAt" INTEGER NOT NULL,
376
+ "updatedAt" INTEGER NOT NULL
377
+ );
378
+ `;
379
+
380
+ const authDisplayName = (authProvider) =>
381
+ authProvider === "auth0" ? "Auth0" : "BetterAuth";
382
+
279
383
  const loginRouteTemplate = (namespace, authProvider) => `module ${namespace}.Routes.Login exposing (page, action)
280
384
 
281
385
  import ElmSsr.Action as Action exposing (Action)
282
386
  import ElmSsr.Document exposing (Document)
283
- import ElmSsr.Html exposing (a, div, p, text)
284
- import ElmSsr.Html.Attributes as Attr
387
+ import ElmSsr.Html exposing (a, div, h1, p, span, text)
388
+ import ElmSsr.Html.Attributes exposing (class, href)
285
389
  import ElmSsr.Loader as Loader exposing (Loader)
286
390
  import ElmSsr.Page as Page
287
391
  import ElmSsr.Route exposing (Request)
288
392
  import ${namespace}.View.Shared as Shared
289
393
 
394
+
290
395
  page : Request -> Loader (Document Never)
291
396
  page _ =
292
397
  Loader.succeed view
293
398
 
399
+
294
400
  action : Request -> Action (Document Never)
295
401
  action _ =
296
402
  Action.fail 405 "Method not allowed"
297
403
 
404
+
298
405
  view : Document Never
299
406
  view =
300
407
  Page.page
301
- { title = "Login | elm-ssr"
408
+ { title = "Sign in | elm-ssr"
302
409
  , head = Shared.head
303
410
  , body =
304
- [ Shared.shell "Sign In"
305
- [ div [ Attr.class "login-container" ]
306
- [ p [] [ text "Access user authentication example (${authProvider})." ]
307
- , div [ Attr.style "margin-top" "20px" ]
308
- [ a
309
- [ Attr.class "button primary"
310
- , Attr.href "/api/auth/login"
311
- ]
312
- [ text "Sign In with Auth Provider" ]
411
+ [ Shared.layout "Sign in"
412
+ [ div [ class "auth-page" ]
413
+ [ div [ class "auth-card" ]
414
+ [ div [ class "auth-header" ]
415
+ [ span [ class "auth-logo" ] [ text "◆" ]
416
+ , h1 [ class "auth-title" ] [ text "Welcome back" ]
417
+ , p [ class "auth-subtitle" ] [ text "Sign in to your account to continue." ]
418
+ ]
419
+ , div [ class "auth-body" ]
420
+ [ a [ class "btn btn-primary btn-full", href "/api/auth/login" ]
421
+ [ text "Continue with ${authDisplayName(authProvider)}" ]
422
+ ]
423
+ , p [ class "auth-footer" ]
424
+ [ text "Powered by ${authDisplayName(authProvider)} via elm-ssr" ]
313
425
  ]
314
426
  ]
315
427
  ]
@@ -321,54 +433,144 @@ const profileRouteTemplate = (namespace) => `module ${namespace}.Routes.Profile
321
433
 
322
434
  import ElmSsr.Action as Action exposing (Action)
323
435
  import ElmSsr.Document exposing (Document)
324
- import ElmSsr.Html exposing (a, div, h2, p, text)
325
- import ElmSsr.Html.Attributes as Attr
436
+ import ElmSsr.Html exposing (a, div, h1, p, span, text)
437
+ import ElmSsr.Html.Attributes exposing (class, href)
326
438
  import ElmSsr.Loader as Loader exposing (Loader)
327
439
  import ElmSsr.Page as Page
328
- import ElmSsr.Route as Route exposing (Request)
440
+ import ElmSsr.Route exposing (Request)
329
441
  import ${namespace}.View.Shared as Shared
330
442
  import Json.Decode as Decode
331
443
 
444
+
332
445
  type alias UserProfile =
333
446
  { email : String
334
447
  , name : Maybe String
335
448
  }
336
449
 
450
+
337
451
  userDecoder : Decode.Decoder UserProfile
338
452
  userDecoder =
339
453
  Decode.map2 UserProfile
340
454
  (Decode.field "email" Decode.string)
341
455
  (Decode.maybe (Decode.field "name" Decode.string))
342
456
 
457
+
343
458
  page : Request -> Loader (Document Never)
344
459
  page _ =
345
460
  Loader.requireUser userDecoder "/login" (\\user ->
346
461
  Loader.succeed (view user)
347
462
  )
348
463
 
464
+
349
465
  action : Request -> Action (Document Never)
350
466
  action _ =
351
467
  Action.fail 405 "Method not allowed"
352
468
 
469
+
353
470
  view : UserProfile -> Document Never
354
471
  view user =
355
472
  Page.page
356
473
  { title = "Profile | elm-ssr"
357
474
  , head = Shared.head
358
475
  , body =
359
- [ Shared.shell "User Profile"
360
- [ div []
361
- [ h2 [] [ text ("Welcome, " ++ (Maybe.withDefault user.email user.name)) ]
362
- , p [] [ text ("Email: " ++ user.email) ]
363
- , p [ Attr.style "margin-top" "20px" ]
364
- [ a [ Attr.class "button", Attr.href "/api/auth/logout" ] [ text "Sign Out" ] ]
476
+ [ Shared.layout "Profile"
477
+ [ div [ class "auth-page" ]
478
+ [ div [ class "auth-card" ]
479
+ [ div [ class "auth-header" ]
480
+ [ span [ class "avatar" ]
481
+ [ text (String.left 1 (Maybe.withDefault user.email user.name) |> String.toUpper) ]
482
+ , h1 [ class "auth-title" ]
483
+ [ text (Maybe.withDefault "Your account" user.name) ]
484
+ , p [ class "auth-subtitle" ] [ text user.email ]
485
+ ]
486
+ , div [ class "auth-body" ]
487
+ [ a [ class "btn btn-secondary btn-full", href "/api/auth/logout" ]
488
+ [ text "Sign out" ]
489
+ ]
490
+ ]
365
491
  ]
366
492
  ]
367
493
  ]
368
494
  }
369
495
  `;
370
496
 
371
- const authEndpointTemplate = (authProvider) => `import { generateSessionId, signValue, generateCsrfToken, readSignedCookie } from "elm-ssr/sessions";
497
+ const betterAuthEndpointTemplate = () => `import { betterAuth } from "better-auth";
498
+ import type { Middleware } from "elm-ssr/http";
499
+ import type { RequestSession } from "elm-ssr/sessions";
500
+
501
+ // BetterAuth is initialised per-request to pick up the right DB binding.
502
+ // Production (Cloudflare Workers): env.DB is the D1 binding.
503
+ // Local dev (Bun): falls back to bun:sqlite using app.db.
504
+ const createAuth = (env: any) => {
505
+ const db = env?.DB ?? (() => {
506
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
507
+ const { Database } = require("bun:sqlite");
508
+ return new Database("app.db");
509
+ })();
510
+
511
+ return betterAuth({
512
+ baseURL: (env?.BETTER_AUTH_URL as string) ?? "http://localhost:8787",
513
+ secret: (env?.BETTER_AUTH_SECRET as string) ?? "change-me-in-production",
514
+ database: { type: "sqlite", db },
515
+ emailAndPassword: { enabled: true },
516
+ // Uncomment to add social providers (add matching env vars in .dev.vars):
517
+ // socialProviders: {
518
+ // github: {
519
+ // clientId: env?.GITHUB_CLIENT_ID as string,
520
+ // clientSecret: env?.GITHUB_CLIENT_SECRET as string,
521
+ // },
522
+ // },
523
+ });
524
+ };
525
+
526
+ export const handleAuth = (request: Request, env: any): Promise<Response> =>
527
+ createAuth(env).handler(request);
528
+
529
+ // Reads the BetterAuth session from the request and returns the user.
530
+ const getSessionUser = async (
531
+ request: Request,
532
+ env: any
533
+ ): Promise<{ email: string; name: string } | null> => {
534
+ try {
535
+ const session = await createAuth(env).api.getSession({ headers: request.headers });
536
+ if (!session?.user) return null;
537
+ return { email: session.user.email, name: session.user.name ?? session.user.email };
538
+ } catch {
539
+ return null;
540
+ }
541
+ };
542
+
543
+ // elm-ssr middleware: reads BetterAuth's session and injects the user into
544
+ // context.session so Loader.session / Loader.requireUser work on every request.
545
+ export const betterAuthMiddleware: Middleware = async (context, next) => {
546
+ const user = await getSessionUser(context.request, context.env);
547
+ context.session = {
548
+ id: user ? \`ba:\${user.email}\` : "",
549
+ data: user,
550
+ csrf: "",
551
+ dirty: false,
552
+ destroyed: false,
553
+ isNew: false,
554
+ } as RequestSession;
555
+ return next(context);
556
+ };
557
+ `;
558
+
559
+ const auth0EndpointTemplate = () => `import { generateSessionId, signValue, generateCsrfToken, readSignedCookie } from "elm-ssr/sessions";
560
+
561
+ interface Auth0Config {
562
+ domain: string;
563
+ clientId: string;
564
+ clientSecret: string;
565
+ callbackUrl: string;
566
+ }
567
+
568
+ const getConfig = (env: any): Auth0Config => ({
569
+ domain: (env?.AUTH0_DOMAIN as string) ?? "",
570
+ clientId: (env?.AUTH0_CLIENT_ID as string) ?? "",
571
+ clientSecret: (env?.AUTH0_CLIENT_SECRET as string) ?? "",
572
+ callbackUrl: (env?.AUTH0_CALLBACK_URL as string) ?? "http://localhost:8787/api/auth/callback",
573
+ });
372
574
 
373
575
  export const handleAuth = async (
374
576
  request: Request,
@@ -376,54 +578,88 @@ export const handleAuth = async (
376
578
  options?: { store?: any; secret?: string }
377
579
  ): Promise<Response> => {
378
580
  const url = new URL(request.url);
581
+ const config = getConfig(env);
379
582
 
380
583
  if (url.pathname === "/api/auth/login") {
381
- // Mock login session update for ${authProvider}
382
- let cookieVal = "__elm_ssr_session=" + encodeURIComponent(JSON.stringify({
383
- user: { email: "user@example.com", name: "${authProvider === "better-auth" ? "BetterAuth User" : "Auth0 User"}" }
384
- })) + "; Path=/; HttpOnly; SameSite=Lax";
584
+ if (!config.domain || !config.clientId) {
585
+ return new Response(
586
+ "Auth0 not configured set AUTH0_DOMAIN and AUTH0_CLIENT_ID in .dev.vars",
587
+ { status: 500 }
588
+ );
589
+ }
590
+ const params = new URLSearchParams({
591
+ response_type: "code",
592
+ client_id: config.clientId,
593
+ redirect_uri: config.callbackUrl,
594
+ scope: "openid profile email",
595
+ });
596
+ return Response.redirect(\`https://\${config.domain}/authorize?\${params}\`, 302);
597
+ }
598
+
599
+ if (url.pathname === "/api/auth/callback") {
600
+ const code = url.searchParams.get("code");
601
+ if (!code) return new Response("Missing code", { status: 400 });
602
+
603
+ const tokenRes = await fetch(\`https://\${config.domain}/oauth/token\`, {
604
+ method: "POST",
605
+ headers: { "content-type": "application/json" },
606
+ body: JSON.stringify({
607
+ grant_type: "authorization_code",
608
+ client_id: config.clientId,
609
+ client_secret: config.clientSecret,
610
+ code,
611
+ redirect_uri: config.callbackUrl,
612
+ }),
613
+ });
614
+
615
+ if (!tokenRes.ok) {
616
+ return new Response("Token exchange with Auth0 failed", { status: 502 });
617
+ }
618
+
619
+ const tokens = await tokenRes.json() as { id_token: string };
620
+ const [, payloadB64] = tokens.id_token.split(".");
621
+ const user = JSON.parse(atob(payloadB64)) as { email: string; name?: string; sub: string };
385
622
 
386
623
  if (options?.store && options?.secret) {
387
624
  const sessionId = generateSessionId();
388
625
  await options.store.set(sessionId, {
389
- data: { email: "user@example.com", name: "${authProvider === "better-auth" ? "BetterAuth User" : "Auth0 User"}" },
390
- csrf: generateCsrfToken()
626
+ data: { email: user.email, name: user.name ?? user.email },
627
+ csrf: generateCsrfToken(),
391
628
  });
392
629
  const signed = await signValue(options.secret, sessionId);
393
- cookieVal = "session=" + signed + "; Path=/; HttpOnly; SameSite=Lax";
630
+ return new Response(null, {
631
+ status: 302,
632
+ headers: {
633
+ Location: "/profile",
634
+ "Set-Cookie": \`session=\${signed}; Path=/; HttpOnly; SameSite=Lax\`,
635
+ },
636
+ });
394
637
  }
395
-
396
- return new Response(null, {
397
- status: 302,
398
- headers: {
399
- "Location": "/profile",
400
- "Set-Cookie": cookieVal
401
- }
402
- });
638
+ return new Response(null, { status: 302, headers: { Location: "/profile" } });
403
639
  }
404
640
 
405
641
  if (url.pathname === "/api/auth/logout") {
406
- let cookieVal = "__elm_ssr_session=; Path=/; Max-Age=0; HttpOnly";
407
642
  if (options?.store && options?.secret) {
408
- cookieVal = "session=; Path=/; Max-Age=0; HttpOnly";
409
- const cookieHeader = request.headers.get("cookie");
410
- if (cookieHeader) {
411
- const sessionId = await readSignedCookie(cookieHeader, "session", options.secret);
412
- if (sessionId) {
413
- await options.store.delete(sessionId);
414
- }
643
+ const incoming = request.headers.get("cookie");
644
+ if (incoming) {
645
+ const sessionId = await readSignedCookie(incoming, "session", options.secret);
646
+ if (sessionId) await options.store.delete(sessionId);
415
647
  }
416
648
  }
649
+ const params = new URLSearchParams({
650
+ client_id: config.clientId,
651
+ returnTo: new URL(request.url).origin,
652
+ });
417
653
  return new Response(null, {
418
654
  status: 302,
419
655
  headers: {
420
- "Location": "/login",
421
- "Set-Cookie": cookieVal
422
- }
656
+ "Set-Cookie": "session=; Path=/; Max-Age=0; HttpOnly",
657
+ Location: \`https://\${config.domain}/oidc/logout?\${params}\`,
658
+ },
423
659
  });
424
660
  }
425
661
 
426
- return new Response("${authProvider} Endpoint API Mock", { status: 200 });
662
+ return new Response("Not found", { status: 404 });
427
663
  };
428
664
  `;
429
665
 
@@ -434,6 +670,9 @@ const runtimeTemplate = (appRoot, db = false, auth = undefined) => {
434
670
  const upToRoot = appRoot === "." ? "." : appRoot.split("/").map(() => "..").join("/");
435
671
  const generatedPrefix = `${upToRoot}/generated/${appRoot === "." ? "" : appRoot}`.replace(/\/+$/, "");
436
672
 
673
+ const isBetterAuth = auth === "better-auth";
674
+ const isAuth0 = auth === "auth0";
675
+
437
676
  const imports = [
438
677
  `import { createWorkerApp } from "elm-ssr";`,
439
678
  `import { renderApp, type CompiledElmModule } from "elm-ssr/render";`,
@@ -441,13 +680,16 @@ const runtimeTemplate = (appRoot, db = false, auth = undefined) => {
441
680
  `import { islands, bundleSource } from "${generatedPrefix}/islands-manifest";`,
442
681
  `import { stylesheet } from "./styles";`,
443
682
  `// @ts-expect-error Generated at build time.`,
444
- `import ElmRuntime from "${generatedPrefix}/app.mjs";`
683
+ `import ElmRuntime from "${generatedPrefix}/app.mjs";`,
684
+ `import { inMemoryEffects, cloudflareEffects } from "elm-ssr/effects";`
445
685
  ];
446
686
 
447
- imports.push(`import { inMemoryEffects, cloudflareEffects } from "elm-ssr/effects";`);
448
- if (auth) {
449
- imports.push(`import { handleAuth } from "./src/Endpoints/Auth";`);
687
+ if (isBetterAuth) {
688
+ imports.push(`import { sessionEffects } from "elm-ssr/sessions";`);
689
+ imports.push(`import { handleAuth, betterAuthMiddleware } from "./src/Endpoints/Auth";`);
690
+ } else if (isAuth0) {
450
691
  imports.push(`import { memorySessionStore } from "elm-ssr/sessions";`);
692
+ imports.push(`import { handleAuth } from "./src/Endpoints/Auth";`);
451
693
  }
452
694
 
453
695
  let dbInit = '';
@@ -492,8 +734,9 @@ if (typeof Bun !== "undefined") {
492
734
  },`;
493
735
  }
494
736
 
495
- const effectsConfig = `,
496
- effects: (effect, context) => {
737
+ // BetterAuth: manually wrap effects with sessionEffects (no elm-ssr session cookie).
738
+ // Auth0 / plain: use the effects callback directly.
739
+ const baseEffectsBody = `(effect, context) => {
497
740
  if (context.env) {
498
741
  return cloudflareEffects(${db ? '{ dbBinding: "DB" }' : ''})(effect, context);
499
742
  }
@@ -502,9 +745,14 @@ if (typeof Bun !== "undefined") {
502
745
  })(effect, context);
503
746
  }`;
504
747
 
748
+ const effectsConfig = isBetterAuth
749
+ ? `,\n effects: sessionEffects(${baseEffectsBody}),\n middlewares: [betterAuthMiddleware]`
750
+ : `,\n effects: ${baseEffectsBody}`;
751
+
752
+ // Auth0 keeps elm-ssr session middleware; BetterAuth manages sessions itself.
505
753
  let sessionsConfig = '';
506
754
  let authInit = '';
507
- if (auth) {
755
+ if (isAuth0) {
508
756
  authInit = `\nexport const sessionStore = memorySessionStore();\n`;
509
757
  sessionsConfig = `,
510
758
  sessions: {
@@ -516,9 +764,21 @@ if (typeof Bun !== "undefined") {
516
764
  }
517
765
 
518
766
  let authIntercept = '';
519
- if (auth) {
767
+ if (isBetterAuth) {
768
+ authIntercept = `
769
+ // Delegate all /api/auth/* requests to BetterAuth.
770
+ const baseWorkerFetch = worker.fetch;
771
+ worker.fetch = async (request, env, ctx) => {
772
+ const url = new URL(request.url);
773
+ if (url.pathname.startsWith("/api/auth/")) {
774
+ return handleAuth(request, env);
775
+ }
776
+ return baseWorkerFetch(request, env, ctx);
777
+ };
778
+ `;
779
+ } else if (isAuth0) {
520
780
  authIntercept = `
521
- // Intercept Auth Provider requests
781
+ // Delegate all /api/auth/* requests to the Auth0 handler.
522
782
  const baseWorkerFetch = worker.fetch;
523
783
  worker.fetch = async (request, env, ctx) => {
524
784
  const url = new URL(request.url);
@@ -635,175 +895,337 @@ export default worker;
635
895
  const stylesTemplate = () => `export const stylesheet = \`
636
896
  @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
637
897
 
638
- *, *::before, *::after {
639
- box-sizing: border-box;
640
- }
898
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
641
899
 
642
900
  :root {
643
- color-scheme: light;
901
+ --bg: #f6f5f3;
902
+ --surface: #ffffff;
903
+ --border: #e5e2dd;
904
+ --text: #1a1a1a;
905
+ --text-muted: #6b6b6b;
906
+ --accent: #1a1a1a;
907
+ --accent-hover: #333;
908
+ --radius: 12px;
909
+ --header-h: 60px;
644
910
  font-family: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
645
911
  font-size: 16px;
646
- color: #18222f;
912
+ color: var(--text);
913
+ background: var(--bg);
647
914
  }
648
915
 
649
- body {
650
- margin: 0;
651
- min-height: 100vh;
652
- background:
653
- radial-gradient(circle at top, rgba(255, 255, 255, 0.85), transparent 45%),
654
- linear-gradient(180deg, #f8f3ea 0%, #efe7dc 100%);
916
+ body { min-height: 100vh; }
917
+
918
+ /* ── Layout ────────────────────────────────────────── */
919
+ .page { display: flex; flex-direction: column; min-height: 100vh; }
920
+
921
+ .header {
922
+ height: var(--header-h);
923
+ border-bottom: 1px solid var(--border);
924
+ background: rgba(246, 245, 243, 0.85);
925
+ backdrop-filter: blur(12px);
926
+ position: sticky;
927
+ top: 0;
928
+ z-index: 10;
655
929
  }
656
930
 
657
- .shell {
658
- max-width: 44rem;
931
+ .header-inner {
932
+ max-width: 1100px;
659
933
  margin: 0 auto;
660
- padding: 4rem 1.5rem;
934
+ padding: 0 1.5rem;
935
+ height: 100%;
936
+ display: flex;
937
+ align-items: center;
938
+ justify-content: space-between;
661
939
  }
662
940
 
663
- h1 {
664
- font-size: 2rem;
941
+ .brand {
942
+ display: flex;
943
+ align-items: center;
944
+ gap: 0.5rem;
665
945
  font-weight: 700;
666
- letter-spacing: -0.02em;
667
- margin: 0 0 1rem;
668
- color: #18222f;
669
- }
670
-
671
- h2 {
672
- font-size: 1.4rem;
673
- font-weight: 600;
674
- margin: 0 0 0.75rem;
675
- color: #18222f;
946
+ font-size: 1rem;
947
+ color: var(--text);
948
+ text-decoration: none;
949
+ letter-spacing: -0.01em;
676
950
  }
677
951
 
678
- p {
679
- line-height: 1.6;
680
- margin: 0 0 1rem;
681
- color: #4a5568;
682
- }
952
+ .brand-icon { font-size: 0.85em; opacity: 0.6; }
683
953
 
684
- a.link {
685
- color: #18222f;
686
- text-decoration: underline;
687
- text-underline-offset: 2px;
688
- }
954
+ .nav { display: flex; align-items: center; gap: 0.25rem; }
689
955
 
690
- a.link:hover {
956
+ .nav-link {
957
+ padding: 0.4rem 0.75rem;
958
+ border-radius: 6px;
959
+ font-size: 0.875rem;
960
+ font-weight: 500;
961
+ color: var(--text-muted);
691
962
  text-decoration: none;
963
+ transition: background 0.12s, color 0.12s;
692
964
  }
965
+ .nav-link:hover { background: var(--border); color: var(--text); }
966
+
967
+ .main { flex: 1; padding: 3rem 1.5rem; }
968
+
969
+ .container { max-width: 1100px; margin: 0 auto; }
693
970
 
694
- .button {
971
+ /* ── Typography ────────────────────────────────────── */
972
+ h1 { font-size: 2rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.2; }
973
+ h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.01em; }
974
+ p { line-height: 1.65; color: var(--text-muted); }
975
+
976
+ /* ── Buttons ────────────────────────────────────────── */
977
+ .btn {
695
978
  display: inline-flex;
696
979
  align-items: center;
980
+ justify-content: center;
697
981
  gap: 0.5rem;
698
- border-radius: 999px;
699
- border: 1.5px solid #18222f;
700
- padding: 0.6rem 1.2rem;
982
+ padding: 0.55rem 1.1rem;
983
+ border-radius: 8px;
984
+ border: 1.5px solid transparent;
701
985
  font: inherit;
702
- font-size: 0.9rem;
986
+ font-size: 0.875rem;
703
987
  font-weight: 500;
704
- background: white;
705
- color: #18222f;
706
988
  cursor: pointer;
707
989
  text-decoration: none;
708
- transition: background 0.15s, color 0.15s;
990
+ transition: background 0.12s, color 0.12s, border-color 0.12s;
991
+ white-space: nowrap;
709
992
  }
710
993
 
711
- .button:hover {
712
- background: #f0ebe3;
994
+ .btn-primary {
995
+ background: var(--accent);
996
+ color: white;
997
+ border-color: var(--accent);
713
998
  }
999
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
714
1000
 
715
- .button.primary {
716
- background: #18222f;
717
- color: white;
718
- border-color: #18222f;
1001
+ .btn-secondary {
1002
+ background: var(--surface);
1003
+ color: var(--text);
1004
+ border-color: var(--border);
1005
+ }
1006
+ .btn-secondary:hover { background: var(--bg); }
1007
+
1008
+ .btn-square { width: 2.5rem; height: 2.5rem; padding: 0; font-size: 1.1rem; }
1009
+
1010
+ .btn-full { width: 100%; }
1011
+
1012
+ /* ── Cards ──────────────────────────────────────────── */
1013
+ .card {
1014
+ background: var(--surface);
1015
+ border: 1px solid var(--border);
1016
+ border-radius: var(--radius);
1017
+ padding: 1.5rem;
1018
+ }
1019
+
1020
+ /* ── Hero ────────────────────────────────────────────── */
1021
+ .hero {
1022
+ text-align: center;
1023
+ padding: 5rem 0 4rem;
1024
+ max-width: 640px;
1025
+ margin: 0 auto;
1026
+ }
1027
+
1028
+ .hero-title {
1029
+ font-size: clamp(2.5rem, 6vw, 4rem);
1030
+ font-weight: 800;
1031
+ letter-spacing: -0.04em;
1032
+ margin-bottom: 1rem;
719
1033
  }
720
1034
 
721
- .button.primary:hover {
722
- background: #2d3748;
1035
+ .hero-subtitle {
1036
+ font-size: 1.15rem;
1037
+ margin-bottom: 2rem;
1038
+ max-width: 480px;
1039
+ margin-left: auto;
1040
+ margin-right: auto;
723
1041
  }
724
1042
 
1043
+ .hero-actions {
1044
+ display: flex;
1045
+ gap: 0.75rem;
1046
+ justify-content: center;
1047
+ flex-wrap: wrap;
1048
+ }
1049
+
1050
+ /* ── Features ────────────────────────────────────────── */
1051
+ .features {
1052
+ display: grid;
1053
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1054
+ gap: 1rem;
1055
+ margin-top: 4rem;
1056
+ }
1057
+
1058
+ .feature-card {
1059
+ background: var(--surface);
1060
+ border: 1px solid var(--border);
1061
+ border-radius: var(--radius);
1062
+ padding: 1.5rem;
1063
+ }
1064
+
1065
+ .feature-icon { font-size: 1.5rem; display: block; margin-bottom: 0.75rem; }
1066
+
1067
+ .feature-title {
1068
+ font-size: 1rem;
1069
+ font-weight: 600;
1070
+ margin-bottom: 0.5rem;
1071
+ color: var(--text);
1072
+ }
1073
+
1074
+ .feature-body { font-size: 0.9rem; }
1075
+
1076
+ /* ── Page header ─────────────────────────────────────── */
1077
+ .page-header { margin-bottom: 2rem; }
1078
+ .page-header h1 { margin-bottom: 0.5rem; }
1079
+ .page-subtitle { font-size: 0.95rem; }
1080
+
1081
+ /* ── Counter island ──────────────────────────────────── */
725
1082
  .counter {
726
1083
  display: grid;
727
1084
  grid-template-columns: auto 1fr auto;
728
- gap: 0.75rem;
729
1085
  align-items: center;
730
- margin: 2rem 0;
1086
+ gap: 1rem;
731
1087
  }
732
1088
 
733
- .value {
1089
+ .counter-value {
734
1090
  text-align: center;
735
- font-size: 2.5rem;
1091
+ font-size: 3rem;
736
1092
  font-weight: 700;
737
- color: #18222f;
1093
+ letter-spacing: -0.03em;
738
1094
  }
739
1095
 
740
- .form {
1096
+ /* ── Auth pages ──────────────────────────────────────── */
1097
+ .auth-page {
741
1098
  display: flex;
742
- flex-direction: column;
743
- gap: 1rem;
744
- margin-top: 1.5rem;
1099
+ align-items: flex-start;
1100
+ justify-content: center;
1101
+ padding-top: 3rem;
745
1102
  }
746
1103
 
747
- .field {
748
- display: flex;
749
- flex-direction: column;
750
- gap: 0.35rem;
1104
+ .auth-card {
1105
+ background: var(--surface);
1106
+ border: 1px solid var(--border);
1107
+ border-radius: 16px;
1108
+ padding: 2.5rem;
1109
+ width: 100%;
1110
+ max-width: 380px;
751
1111
  }
752
1112
 
753
- .field label,
754
- .field span {
755
- font-size: 0.875rem;
756
- font-weight: 500;
757
- color: #4a5568;
758
- }
1113
+ .auth-header { text-align: center; margin-bottom: 2rem; }
759
1114
 
760
- .input {
761
- width: 100%;
762
- border-radius: 8px;
763
- border: 1.5px solid #d1cdc7;
764
- padding: 0.65rem 0.9rem;
765
- font: inherit;
766
- font-size: 0.95rem;
767
- background: white;
768
- color: #18222f;
769
- transition: border-color 0.15s;
1115
+ .auth-logo {
1116
+ font-size: 1.5rem;
1117
+ display: block;
1118
+ margin-bottom: 1rem;
770
1119
  }
771
1120
 
772
- .input:focus {
773
- outline: none;
774
- border-color: #18222f;
775
- }
1121
+ .auth-title { font-size: 1.5rem; margin-bottom: 0.5rem; }
1122
+ .auth-subtitle { font-size: 0.9rem; }
776
1123
 
777
- .login-container {
778
- margin-top: 2rem;
779
- padding: 2rem;
780
- background: white;
781
- border-radius: 12px;
782
- border: 1px solid rgba(0, 0, 0, 0.08);
783
- max-width: 22rem;
1124
+ .auth-body { display: flex; flex-direction: column; gap: 0.75rem; }
1125
+
1126
+ .auth-footer {
1127
+ text-align: center;
1128
+ font-size: 0.75rem;
1129
+ color: var(--text-muted);
1130
+ margin-top: 1.5rem;
784
1131
  }
785
1132
 
786
- .card {
787
- padding: 1.5rem;
788
- background: white;
789
- border-radius: 12px;
790
- border: 1px solid rgba(0, 0, 0, 0.08);
1133
+ .avatar {
1134
+ display: inline-flex;
1135
+ align-items: center;
1136
+ justify-content: center;
1137
+ width: 3.5rem;
1138
+ height: 3.5rem;
1139
+ border-radius: 50%;
1140
+ background: var(--accent);
1141
+ color: white;
1142
+ font-size: 1.5rem;
1143
+ font-weight: 700;
791
1144
  margin-bottom: 1rem;
792
1145
  }
793
1146
 
794
- .muted {
795
- color: #718096;
796
- font-size: 0.875rem;
1147
+ /* ── Error page ──────────────────────────────────────── */
1148
+ .error-page {
1149
+ text-align: center;
1150
+ padding: 5rem 0;
797
1151
  }
798
1152
 
799
- .error {
800
- color: #c53030;
801
- font-size: 0.875rem;
802
- margin-top: 0.25rem;
1153
+ .error-code {
1154
+ font-size: 6rem;
1155
+ font-weight: 800;
1156
+ letter-spacing: -0.05em;
1157
+ color: var(--border);
1158
+ margin-bottom: 0.5rem;
1159
+ }
1160
+
1161
+ .error-message { margin-bottom: 2rem; }
1162
+
1163
+ /* ── Forms ───────────────────────────────────────────── */
1164
+ .field { display: flex; flex-direction: column; gap: 0.35rem; }
1165
+ .field label, .field span { font-size: 0.875rem; font-weight: 500; }
1166
+
1167
+ .input {
1168
+ width: 100%;
1169
+ border-radius: 8px;
1170
+ border: 1.5px solid var(--border);
1171
+ padding: 0.6rem 0.9rem;
1172
+ font: inherit;
1173
+ font-size: 0.9rem;
1174
+ background: white;
1175
+ color: var(--text);
1176
+ transition: border-color 0.12s;
803
1177
  }
1178
+ .input:focus { outline: none; border-color: var(--accent); }
1179
+
1180
+ .error-hint { color: #c53030; font-size: 0.8rem; margin-top: 0.25rem; }
804
1181
  \`;
805
1182
  `;
806
1183
 
1184
+ const devVarsContent = (auth) => {
1185
+ if (auth === "better-auth") {
1186
+ return `GREETING="Hello from your local .dev.vars file!"
1187
+ BETTER_AUTH_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1188
+ BETTER_AUTH_URL="http://localhost:8787"
1189
+ # Uncomment to enable social providers in src/Endpoints/Auth.ts:
1190
+ # GITHUB_CLIENT_ID="your-github-client-id"
1191
+ # GITHUB_CLIENT_SECRET="your-github-client-secret"
1192
+ `;
1193
+ }
1194
+ if (auth === "auth0") {
1195
+ return `GREETING="Hello from your local .dev.vars file!"
1196
+ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1197
+ AUTH0_DOMAIN="your-tenant.auth0.com"
1198
+ AUTH0_CLIENT_ID="your-client-id"
1199
+ AUTH0_CLIENT_SECRET="your-client-secret"
1200
+ AUTH0_CALLBACK_URL="http://localhost:8787/api/auth/callback"
1201
+ `;
1202
+ }
1203
+ return `GREETING="Hello from your local .dev.vars file!"
1204
+ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1205
+ `;
1206
+ };
1207
+
1208
+ const rootEnvContent = (auth) => {
1209
+ if (auth === "better-auth") {
1210
+ return `GREETING="Hello from your local .env file!"
1211
+ BETTER_AUTH_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1212
+ BETTER_AUTH_URL="http://localhost:8787"
1213
+ `;
1214
+ }
1215
+ if (auth === "auth0") {
1216
+ return `GREETING="Hello from your local .env file!"
1217
+ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1218
+ AUTH0_DOMAIN="your-tenant.auth0.com"
1219
+ AUTH0_CLIENT_ID="your-client-id"
1220
+ AUTH0_CLIENT_SECRET="your-client-secret"
1221
+ AUTH0_CALLBACK_URL="http://localhost:8787/api/auth/callback"
1222
+ `;
1223
+ }
1224
+ return `GREETING="Hello from your local .env file!"
1225
+ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1226
+ `;
1227
+ };
1228
+
807
1229
  const filesForApp = (name, appRoot, options = {}) => {
808
1230
  const namespace = toPascalCase(name);
809
1231
  const db = options.db || !!options.auth;
@@ -815,29 +1237,34 @@ const filesForApp = (name, appRoot, options = {}) => {
815
1237
  { path: `${appRoot}/runtime.ts`, content: runtimeTemplate(appRoot, db, auth) },
816
1238
  { path: `${appRoot}/worker.ts`, content: workerTemplate() },
817
1239
  { path: `${appRoot}/styles.ts`, content: stylesTemplate() },
818
- { path: `${appRoot}/src/${namespace}/View/Shared.elm`, content: sharedTemplate(namespace) },
819
- { path: `${appRoot}/src/${namespace}/Routes/Index.elm`, content: indexRouteTemplate(namespace) },
1240
+ { path: `${appRoot}/src/${namespace}/View/Shared.elm`, content: sharedTemplate(namespace, auth) },
1241
+ { path: `${appRoot}/src/${namespace}/Routes/Index.elm`, content: indexRouteTemplate(namespace, auth) },
820
1242
  { path: `${appRoot}/src/${namespace}/Routes/Counter.elm`, content: counterRouteTemplate(namespace) },
821
1243
  { path: `${appRoot}/src/${namespace}/Routes/NotFound.elm`, content: notFoundRouteTemplate(namespace) },
822
1244
  { path: `${appRoot}/src/${namespace}/Islands/Counter.elm`, content: counterIslandTemplate(namespace) },
823
1245
  {
824
1246
  path: `${appRoot}/.dev.vars`,
825
- content: `GREETING="Hello from your local .dev.vars file!"
826
- SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
827
- `
1247
+ content: devVarsContent(auth)
828
1248
  }
829
1249
  ];
830
1250
 
831
1251
  if (db) {
832
- files.push({
833
- path: `${appRoot}/migrations/0001_init.sql`,
834
- content: `CREATE TABLE IF NOT EXISTS users (
1252
+ let migrationContent;
1253
+ if (auth === "better-auth") {
1254
+ migrationContent = betterAuthMigrationTemplate();
1255
+ } else if (auth === "auth0") {
1256
+ migrationContent = auth0MigrationTemplate();
1257
+ } else {
1258
+ migrationContent = `CREATE TABLE IF NOT EXISTS users (
835
1259
  id INTEGER PRIMARY KEY AUTOINCREMENT,
836
1260
  email TEXT NOT NULL UNIQUE,
837
1261
  name TEXT,
838
1262
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
839
- );
840
- `
1263
+ );\n`;
1264
+ }
1265
+ files.push({
1266
+ path: `${appRoot}/migrations/0001_init.sql`,
1267
+ content: migrationContent
841
1268
  });
842
1269
  }
843
1270
 
@@ -852,7 +1279,7 @@ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32
852
1279
  });
853
1280
  files.push({
854
1281
  path: `${appRoot}/src/Endpoints/Auth.ts`,
855
- content: authEndpointTemplate(auth)
1282
+ content: auth === "better-auth" ? betterAuthEndpointTemplate() : auth0EndpointTemplate()
856
1283
  });
857
1284
  }
858
1285
 
@@ -872,58 +1299,56 @@ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32
872
1299
  }
873
1300
 
874
1301
  @layer components {
875
- .shell {
876
- @apply max-w-2xl mx-auto px-6 py-16;
877
- }
878
-
879
- .button {
880
- @apply inline-flex items-center gap-2 rounded-full border border-gray-900
881
- px-5 py-2.5 text-sm font-medium bg-white text-gray-900 no-underline
882
- cursor-pointer transition-colors hover:bg-gray-50;
883
- }
884
-
885
- .button.primary {
886
- @apply bg-gray-900 text-white hover:bg-gray-700;
887
- }
888
-
889
- .counter {
890
- @apply my-8 grid items-center gap-3;
891
- grid-template-columns: auto 1fr auto;
892
- }
893
-
894
- .value {
895
- @apply text-center text-5xl font-bold text-gray-900;
896
- }
897
-
898
- .form {
899
- @apply flex flex-col gap-4 mt-6;
900
- }
901
-
902
- .field {
903
- @apply flex flex-col gap-1.5;
904
- }
905
-
906
- .input {
907
- @apply w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm
908
- bg-white text-gray-900 focus:outline-none focus:border-gray-900
909
- transition-colors;
910
- }
911
-
912
- .login-container {
913
- @apply mt-8 p-8 bg-white rounded-xl border border-gray-100 max-w-sm;
914
- }
915
-
916
- .card {
917
- @apply p-6 bg-white rounded-xl border border-gray-100 mb-4;
918
- }
919
-
920
- .muted {
921
- @apply text-gray-500 text-sm;
922
- }
923
-
924
- .error {
925
- @apply text-red-600 text-sm mt-1;
926
- }
1302
+ .page { @apply flex flex-col min-h-screen; }
1303
+ .header { @apply sticky top-0 z-10 border-b border-gray-200 bg-white/80 backdrop-blur-md; }
1304
+ .header-inner { @apply max-w-6xl mx-auto px-6 h-16 flex items-center justify-between; }
1305
+ .brand { @apply flex items-center gap-2 font-bold text-gray-900 no-underline; }
1306
+ .nav { @apply flex items-center gap-1; }
1307
+ .nav-link { @apply px-3 py-1.5 rounded-md text-sm font-medium text-gray-500 no-underline hover:bg-gray-100 hover:text-gray-900 transition-colors; }
1308
+ .main { @apply flex-1 px-6 py-12; }
1309
+ .container { @apply max-w-6xl mx-auto; }
1310
+ .card { @apply bg-white border border-gray-200 rounded-xl p-6; }
1311
+
1312
+ .btn { @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm font-medium no-underline cursor-pointer transition-colors whitespace-nowrap; }
1313
+ .btn-primary { @apply bg-gray-900 text-white border-gray-900 hover:bg-gray-700; }
1314
+ .btn-secondary { @apply bg-white text-gray-900 border-gray-200 hover:bg-gray-50; }
1315
+ .btn-square { @apply w-10 h-10 p-0 text-lg; }
1316
+ .btn-full { @apply w-full; }
1317
+
1318
+ .hero { @apply text-center py-20 max-w-2xl mx-auto; }
1319
+ .hero-title { @apply text-6xl font-extrabold tracking-tight mb-4; }
1320
+ .hero-subtitle { @apply text-lg text-gray-500 mb-8 max-w-lg mx-auto; }
1321
+ .hero-actions { @apply flex gap-3 justify-center flex-wrap; }
1322
+
1323
+ .features { @apply grid gap-4 mt-16; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
1324
+ .feature-card { @apply bg-white border border-gray-200 rounded-xl p-6; }
1325
+ .feature-icon { @apply text-2xl block mb-3; }
1326
+ .feature-title { @apply text-base font-semibold mb-2; }
1327
+ .feature-body { @apply text-sm text-gray-500; }
1328
+
1329
+ .page-header { @apply mb-8; }
1330
+ .page-subtitle { @apply text-gray-500 mt-2; }
1331
+
1332
+ .counter { @apply grid items-center gap-4; grid-template-columns: auto 1fr auto; }
1333
+ .counter-value { @apply text-center text-5xl font-bold tracking-tight; }
1334
+
1335
+ .auth-page { @apply flex justify-center pt-12; }
1336
+ .auth-card { @apply bg-white border border-gray-200 rounded-2xl p-10 w-full max-w-sm; }
1337
+ .auth-header { @apply text-center mb-8; }
1338
+ .auth-logo { @apply text-2xl block mb-4; }
1339
+ .auth-title { @apply text-2xl font-bold tracking-tight mb-2; }
1340
+ .auth-subtitle { @apply text-sm text-gray-500; }
1341
+ .auth-body { @apply flex flex-col gap-3; }
1342
+ .auth-footer { @apply text-center text-xs text-gray-400 mt-6; }
1343
+ .avatar { @apply inline-flex items-center justify-center w-14 h-14 rounded-full bg-gray-900 text-white text-2xl font-bold mb-4; }
1344
+
1345
+ .error-page { @apply text-center py-20; }
1346
+ .error-code { @apply text-8xl font-extrabold text-gray-200 mb-2; }
1347
+ .error-message { @apply text-gray-500 mb-8; }
1348
+
1349
+ .field { @apply flex flex-col gap-1.5; }
1350
+ .input { @apply w-full rounded-lg border border-gray-300 px-3.5 py-2.5 text-sm bg-white focus:outline-none focus:border-gray-900 transition-colors; }
1351
+ .error-hint { @apply text-red-600 text-xs mt-1; }
927
1352
  }
928
1353
  `
929
1354
  });
@@ -955,7 +1380,7 @@ const normalizeAppRoot = (rawRoot, name) => {
955
1380
  return candidate;
956
1381
  };
957
1382
 
958
- const ensurePackageJson = async (rootPath, appName) => {
1383
+ const ensurePackageJson = async (rootPath, appName, options = {}) => {
959
1384
  const packageJsonPath = resolve(rootPath, "package.json");
960
1385
  let packageJson = {};
961
1386
  try {
@@ -978,9 +1403,15 @@ const ensurePackageJson = async (rootPath, appName) => {
978
1403
  ...packageJson.scripts
979
1404
  };
980
1405
 
1406
+ const extraDeps = {};
1407
+ if (options.auth === "better-auth") {
1408
+ extraDeps["better-auth"] = "latest";
1409
+ }
1410
+
981
1411
  packageJson.devDependencies = {
982
1412
  "elm-ssr": "latest",
983
1413
  wrangler: "^4.0.0",
1414
+ ...extraDeps,
984
1415
  ...packageJson.devDependencies
985
1416
  };
986
1417
 
@@ -1026,9 +1457,7 @@ export const createAppScaffold = async (rootPath, name, options = {}) => {
1026
1457
  } catch {}
1027
1458
 
1028
1459
  if (!envExists) {
1029
- await writeFile(envPath, `GREETING="Hello from your local .env file!"
1030
- SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1031
- `, "utf8");
1460
+ await writeFile(envPath, rootEnvContent(options.auth), "utf8");
1032
1461
  }
1033
1462
 
1034
1463
  await writeWorkspaceConfig(rootPath, {
@@ -1036,7 +1465,7 @@ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32
1036
1465
  apps: [...config.apps, configEntry]
1037
1466
  });
1038
1467
 
1039
- await ensurePackageJson(rootPath, name);
1468
+ await ensurePackageJson(rootPath, name, options);
1040
1469
 
1041
1470
  return configEntry;
1042
1471
  };