handzon-core 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/package.json +74 -0
  2. package/src/collections.ts +150 -0
  3. package/src/components/Footer.astro +85 -0
  4. package/src/components/Navbar.astro +74 -0
  5. package/src/components/Progress.tsx +36 -0
  6. package/src/components/Sidebar.astro +162 -0
  7. package/src/components/StepNav.astro +107 -0
  8. package/src/components/ai/ByokSetup.tsx +90 -0
  9. package/src/components/ai/ChatButton.tsx +30 -0
  10. package/src/components/ai/ChatPanel.tsx +244 -0
  11. package/src/components/auth/SignInButton.astro +41 -0
  12. package/src/components/auth/UserMenu.astro +79 -0
  13. package/src/components/auth/UserMenu.tsx +136 -0
  14. package/src/components/home/FilterBar.tsx +152 -0
  15. package/src/components/home/Hero.astro +60 -0
  16. package/src/components/home/Pagination.tsx +89 -0
  17. package/src/components/home/ResumeRail.tsx +50 -0
  18. package/src/components/home/TutorialCard.astro +185 -0
  19. package/src/components/mdx/Callout.astro +77 -0
  20. package/src/components/mdx/Checkpoint.astro +14 -0
  21. package/src/components/mdx/Checkpoint.tsx +49 -0
  22. package/src/components/mdx/Diff.astro +6 -0
  23. package/src/components/mdx/Diff.tsx +100 -0
  24. package/src/components/mdx/Download.astro +37 -0
  25. package/src/components/mdx/Embed.astro +56 -0
  26. package/src/components/mdx/File.astro +28 -0
  27. package/src/components/mdx/FileTree.astro +6 -0
  28. package/src/components/mdx/FileTree.tsx +71 -0
  29. package/src/components/mdx/Hint.astro +51 -0
  30. package/src/components/mdx/Mermaid.astro +6 -0
  31. package/src/components/mdx/Mermaid.tsx +47 -0
  32. package/src/components/mdx/Playground.astro +6 -0
  33. package/src/components/mdx/Playground.tsx +34 -0
  34. package/src/components/mdx/Quiz.astro +6 -0
  35. package/src/components/mdx/Quiz.tsx +102 -0
  36. package/src/components/mdx/Recap.astro +65 -0
  37. package/src/components/mdx/Reveal.astro +7 -0
  38. package/src/components/mdx/Reveal.tsx +25 -0
  39. package/src/components/mdx/Step.astro +12 -0
  40. package/src/components/mdx/Steps.astro +40 -0
  41. package/src/components/mdx/Tab.astro +22 -0
  42. package/src/components/mdx/Tabs.astro +67 -0
  43. package/src/components/mdx/Terminal.astro +6 -0
  44. package/src/components/mdx/Terminal.tsx +47 -0
  45. package/src/index.ts +55 -0
  46. package/src/layouts/BaseLayout.astro +112 -0
  47. package/src/layouts/TutorialLayout.astro +218 -0
  48. package/src/lib/ai/client.ts +92 -0
  49. package/src/lib/ai/context.ts +97 -0
  50. package/src/lib/content.ts +73 -0
  51. package/src/lib/mdx-components.ts +47 -0
  52. package/src/lib/progress/local.ts +89 -0
  53. package/src/lib/progress/remote.ts +199 -0
  54. package/src/lib/progress/types.ts +63 -0
  55. package/src/lib/progress/useProgress.ts +117 -0
  56. package/src/lib/rehype-mermaid-passthrough.ts +31 -0
  57. package/src/pages/Home.astro +408 -0
  58. package/src/pages/TutorialLanding.astro +324 -0
  59. package/src/pages/TutorialStep.astro +67 -0
  60. package/src/pages/paths.ts +36 -0
  61. package/src/server/auth/config.ts +102 -0
  62. package/src/server/auth/schema.ts +66 -0
  63. package/src/server/auth/session.ts +27 -0
  64. package/src/server/auth.ts +127 -0
  65. package/src/server/db/client.ts +14 -0
  66. package/src/server/db/migrate.ts +29 -0
  67. package/src/server/db/schema.ts +65 -0
  68. package/src/server/handlers/healthz.ts +6 -0
  69. package/src/server/handlers/progress.ts +90 -0
  70. package/src/server/handlers/tutorialStats.ts +67 -0
  71. package/src/server/http.ts +33 -0
  72. package/src/types/ai.ts +17 -0
  73. package/styles/base.css +127 -0
  74. package/styles/components/a11y.css +12 -0
  75. package/styles/components/byok.css +50 -0
  76. package/styles/components/chat.css +304 -0
  77. package/styles/components/checkpoint.css +49 -0
  78. package/styles/components/diff.css +44 -0
  79. package/styles/components/expressive-code.css +61 -0
  80. package/styles/components/filetree.css +68 -0
  81. package/styles/components/mermaid.css +19 -0
  82. package/styles/components/modal.css +25 -0
  83. package/styles/components/progress.css +19 -0
  84. package/styles/components/quiz.css +101 -0
  85. package/styles/components/reveal.css +25 -0
  86. package/styles/components/tabs.css +60 -0
  87. package/styles/components/terminal.css +55 -0
  88. package/styles/components.css +28 -0
  89. package/styles/global.css +15 -0
@@ -0,0 +1,324 @@
1
+ ---
2
+ import BaseLayout from "../layouts/BaseLayout.astro";
3
+ import { getStepsForTutorial, parseStepId, sumDurations } from "../lib/content.ts";
4
+ import type { TutorialEntry } from "../lib/content.ts";
5
+
6
+ interface Props {
7
+ tutorial: TutorialEntry;
8
+ siteName?: string;
9
+ tagline?: string;
10
+ logoUrl?: string;
11
+ faviconUrl?: string;
12
+ repoUrl?: string;
13
+ }
14
+
15
+ const { tutorial, siteName, tagline, logoUrl, faviconUrl, repoUrl } = Astro.props;
16
+ const steps = await getStepsForTutorial(tutorial.id);
17
+ const duration = tutorial.data.estimatedDuration ?? sumDurations(steps) ?? "";
18
+ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
19
+ ---
20
+ <BaseLayout
21
+ title={tutorial.data.title}
22
+ description={tutorial.data.description}
23
+ siteName={siteName}
24
+ tagline={tagline}
25
+ logoUrl={logoUrl}
26
+ faviconUrl={faviconUrl}
27
+ repoUrl={repoUrl}
28
+ >
29
+ <div class="landing">
30
+ <a class="back" href="/">← All tutorials</a>
31
+
32
+ <header class="hero">
33
+ <div class="hero-meta">
34
+ <span class={`pill pill-${tutorial.data.difficulty}`}>{tutorial.data.difficulty}</span>
35
+ {duration && <span class="pill pill-neutral">⏱ {duration}</span>}
36
+ <span class="pill pill-neutral">{steps.length} {steps.length === 1 ? "step" : "steps"}</span>
37
+ </div>
38
+
39
+ <h1>{tutorial.data.title}</h1>
40
+ <p class="desc">{tutorial.data.description}</p>
41
+
42
+ <div class="hero-actions">
43
+ {firstStepSlug && (
44
+ <a class="cta" href={`/${tutorial.id}/${firstStepSlug}`} data-tutorial-slug={tutorial.id}>
45
+ <span class="cta-label">Start tutorial</span>
46
+ <span class="cta-arrow" aria-hidden="true">→</span>
47
+ </a>
48
+ )}
49
+ {tutorial.data.author && (
50
+ <div class="author">
51
+ By <strong>{tutorial.data.author.name}</strong>
52
+ {tutorial.data.publishedAt && (
53
+ <> · {new Date(tutorial.data.publishedAt).toLocaleDateString()}</>
54
+ )}
55
+ </div>
56
+ )}
57
+ </div>
58
+
59
+ {tutorial.data.tags.length > 0 && (
60
+ <div class="hero-tags" aria-label="Topics">
61
+ {tutorial.data.tags.map((tag) => (
62
+ <a class="hero-tag" href={`/?tag=${encodeURIComponent(tag)}`}>#{tag}</a>
63
+ ))}
64
+ </div>
65
+ )}
66
+ </header>
67
+
68
+ {tutorial.data.prerequisites.length > 0 && (
69
+ <section class="block">
70
+ <h2>Prerequisites</h2>
71
+ <ul class="prereqs">{tutorial.data.prerequisites.map((p) => <li>{p}</li>)}</ul>
72
+ </section>
73
+ )}
74
+
75
+ <section class="block">
76
+ <h2>Steps</h2>
77
+ <ol class="step-list">
78
+ {steps.map((s, i) => {
79
+ const { stepSlug } = parseStepId(s.id);
80
+ return (
81
+ <li>
82
+ <a href={`/${tutorial.id}/${stepSlug}`}>
83
+ <span class="step-num">{String(i + 1).padStart(2, "0")}</span>
84
+ <span class="step-body">
85
+ <strong>{s.data.title}</strong>
86
+ {s.data.summary && <span class="step-summary">{s.data.summary}</span>}
87
+ </span>
88
+ {s.data.duration && <span class="step-dur">{s.data.duration}</span>}
89
+ <span class="step-chevron" aria-hidden="true">→</span>
90
+ </a>
91
+ </li>
92
+ );
93
+ })}
94
+ </ol>
95
+ </section>
96
+ </div>
97
+
98
+ <script>
99
+ // Resume from last visited if we have one.
100
+ import { getStore } from "../lib/progress/local.ts";
101
+ const cta = document.querySelector<HTMLAnchorElement>("[data-tutorial-slug]");
102
+ if (cta) {
103
+ const slug = cta.dataset.tutorialSlug!;
104
+ const last = getStore().get().lastVisited[slug];
105
+ const step = typeof last === "string" ? last : last?.step;
106
+ if (step) {
107
+ cta.textContent = `Continue from "${step}" →`;
108
+ cta.setAttribute("href", `/${slug}/${step}`);
109
+ }
110
+ }
111
+ </script>
112
+ </BaseLayout>
113
+
114
+ <style>
115
+ .landing {
116
+ max-width: 56rem;
117
+ margin: 0 auto;
118
+ padding: 2rem clamp(1rem, 4vw, 3rem) 5rem;
119
+ }
120
+ .back {
121
+ display: inline-block;
122
+ font-family: var(--font-mono);
123
+ font-size: 0.75em;
124
+ text-transform: uppercase;
125
+ letter-spacing: 0.06em;
126
+ color: var(--color-muted);
127
+ text-decoration: none;
128
+ margin-bottom: 1.5rem;
129
+ }
130
+ .back:hover { color: var(--color-accent); }
131
+ .hero {
132
+ position: relative;
133
+ padding: 2rem 2rem 2.25rem;
134
+ background: linear-gradient(
135
+ 180deg,
136
+ color-mix(in oklab, var(--color-accent) 8%, var(--color-bg)),
137
+ var(--color-bg) 80%
138
+ );
139
+ border: var(--border-default, 2px) solid var(--color-border);
140
+ border-left-width: var(--border-thick, 4px);
141
+ border-left-color: var(--color-accent);
142
+ margin-bottom: 3rem;
143
+ }
144
+ .hero-meta {
145
+ display: flex;
146
+ flex-wrap: wrap;
147
+ gap: 0.4rem;
148
+ margin-bottom: 1.1rem;
149
+ }
150
+ .pill {
151
+ display: inline-flex;
152
+ align-items: center;
153
+ gap: 0.25rem;
154
+ padding: 0.22rem 0.55rem;
155
+ font-family: var(--font-mono);
156
+ font-size: 0.7em;
157
+ text-transform: uppercase;
158
+ letter-spacing: 0.06em;
159
+ border: var(--border-default, 2px) solid var(--color-border);
160
+ color: var(--color-muted);
161
+ background: var(--color-bg);
162
+ }
163
+ .pill-soft {
164
+ border-color: transparent;
165
+ background: var(--color-surface);
166
+ }
167
+ .pill-beginner { color: var(--color-success); border-color: var(--color-success); }
168
+ .pill-intermediate { color: var(--color-warn); border-color: var(--color-warn); }
169
+ .pill-advanced { color: var(--color-danger); border-color: var(--color-danger); }
170
+
171
+ /* Topics — quiet, hash-prefixed, sitting below the CTA. No pill
172
+ * boxes (which fought with the difficulty/duration row); just
173
+ * monospace muted text, clickable to filter the homepage. */
174
+ .hero-tags {
175
+ display: flex;
176
+ flex-wrap: wrap;
177
+ gap: 0.65rem 1rem;
178
+ margin-top: 1.75rem;
179
+ padding-top: 1.25rem;
180
+ border-top: 1px solid var(--color-border);
181
+ }
182
+ .hero-tag {
183
+ font-family: var(--font-mono);
184
+ font-size: 0.75em;
185
+ color: var(--color-muted);
186
+ text-decoration: none;
187
+ transition: color 0.12s ease;
188
+ }
189
+ .hero-tag:hover { color: var(--color-accent); }
190
+ h1 {
191
+ font-size: clamp(2.1rem, 4.5vw, 3rem);
192
+ font-weight: 800;
193
+ margin: 0 0 0.75rem;
194
+ letter-spacing: -0.025em;
195
+ line-height: 1.05;
196
+ }
197
+ .desc {
198
+ font-size: clamp(1rem, 1.5vw, 1.15rem);
199
+ line-height: 1.55;
200
+ color: var(--color-muted);
201
+ margin: 0 0 1.75rem;
202
+ max-width: 50ch;
203
+ }
204
+ .hero-actions {
205
+ display: flex;
206
+ flex-wrap: wrap;
207
+ align-items: center;
208
+ gap: 1.25rem;
209
+ }
210
+ .author { color: var(--color-muted); font-size: 0.85em; }
211
+ .cta {
212
+ display: inline-flex;
213
+ align-items: center;
214
+ gap: 0.65rem;
215
+ padding: 0.85rem 1.4rem;
216
+ background: color-mix(in oklab, var(--color-accent) 10%, transparent);
217
+ color: var(--color-accent);
218
+ border: 1px solid var(--color-accent);
219
+ font-weight: 700;
220
+ font-size: 0.95em;
221
+ text-decoration: none;
222
+ cursor: pointer;
223
+ transition: background 0.12s ease, color 0.12s ease;
224
+ }
225
+ .cta:hover {
226
+ background: var(--color-accent);
227
+ color: var(--color-accent-fg);
228
+ }
229
+ .cta:hover .cta-arrow { transform: translateX(3px); }
230
+ .cta-arrow {
231
+ display: inline-block;
232
+ transition: transform 0.12s ease;
233
+ font-family: var(--font-mono);
234
+ }
235
+ .block { margin-top: 3rem; }
236
+ .block h2 {
237
+ font-size: 0.85rem;
238
+ text-transform: uppercase;
239
+ letter-spacing: 0.1em;
240
+ color: var(--color-accent);
241
+ margin: 0 0 1rem;
242
+ font-weight: 700;
243
+ }
244
+ .prereqs {
245
+ list-style: none;
246
+ padding: 0;
247
+ margin: 0;
248
+ display: grid;
249
+ gap: 0.4rem;
250
+ }
251
+ .prereqs li {
252
+ display: flex;
253
+ align-items: center;
254
+ gap: 0.7rem;
255
+ padding: 0.55rem 0.85rem;
256
+ background: var(--color-surface);
257
+ border: 1px solid var(--color-border);
258
+ color: var(--color-fg);
259
+ font-size: 0.95em;
260
+ line-height: 1.4;
261
+ }
262
+ .prereqs li::before {
263
+ content: "✓";
264
+ display: inline-grid;
265
+ place-items: center;
266
+ width: 1.1rem;
267
+ height: 1.1rem;
268
+ border: 1px solid color-mix(in oklab, var(--color-accent) 40%, var(--color-border));
269
+ background: color-mix(in oklab, var(--color-accent) 12%, var(--color-bg));
270
+ color: var(--color-accent);
271
+ font-family: var(--font-mono);
272
+ font-size: 0.7em;
273
+ font-weight: 700;
274
+ flex-shrink: 0;
275
+ }
276
+ .step-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.5rem; }
277
+ .step-list li { border: 1px solid var(--color-border); background: var(--color-bg); }
278
+ .step-list a {
279
+ display: grid;
280
+ grid-template-columns: auto 1fr auto auto;
281
+ gap: 1rem;
282
+ align-items: center;
283
+ padding: 0.9rem 1rem;
284
+ text-decoration: none;
285
+ color: var(--color-fg);
286
+ transition: background 0.12s ease, border-color 0.12s ease;
287
+ }
288
+ .step-list li:hover { border-color: var(--color-accent); }
289
+ .step-list a:hover { background: var(--color-surface); }
290
+ .step-list a:hover .step-chevron { color: var(--color-accent); transform: translateX(3px); }
291
+ .step-num {
292
+ display: inline-grid;
293
+ place-items: center;
294
+ min-width: 2.25rem;
295
+ height: 2.25rem;
296
+ padding: 0 0.5rem;
297
+ font-family: var(--font-mono);
298
+ font-size: 0.8em;
299
+ font-weight: 700;
300
+ color: var(--color-accent);
301
+ background: color-mix(in oklab, var(--color-accent) 10%, var(--color-bg));
302
+ border: 1px solid color-mix(in oklab, var(--color-accent) 35%, var(--color-border));
303
+ }
304
+ .step-body { display: grid; gap: 0.15rem; min-width: 0; }
305
+ .step-body strong { font-weight: 600; font-size: 1em; letter-spacing: -0.005em; }
306
+ .step-summary { color: var(--color-muted); font-size: 0.88em; }
307
+ .step-dur {
308
+ font-family: var(--font-mono);
309
+ font-size: 0.75em;
310
+ color: var(--color-muted);
311
+ padding: 0.2rem 0.5rem;
312
+ border: 1px solid var(--color-border);
313
+ }
314
+ .step-chevron {
315
+ font-family: var(--font-mono);
316
+ color: var(--color-muted);
317
+ transition: transform 0.12s ease, color 0.12s ease;
318
+ }
319
+ @media (max-width: 640px) {
320
+ .hero { padding: 1.5rem 1.25rem 1.75rem; }
321
+ .step-list a { grid-template-columns: auto 1fr auto; padding: 0.75rem; }
322
+ .step-chevron { display: none; }
323
+ }
324
+ </style>
@@ -0,0 +1,67 @@
1
+ ---
2
+ import { render } from "astro:content";
3
+ import TutorialLayout from "../layouts/TutorialLayout.astro";
4
+ import ChatButton from "../components/ai/ChatButton.tsx";
5
+ import { parseStepId } from "../lib/content.ts";
6
+ import type { StepEntry, TutorialEntry } from "../lib/content.ts";
7
+ import { mdxComponents } from "../lib/mdx-components.ts";
8
+ import { buildContext } from "../lib/ai/context.ts";
9
+ import { emptyState } from "../lib/progress/types.ts";
10
+ import type { AiConfig } from "../types/ai.ts";
11
+
12
+ interface Props {
13
+ tutorial: TutorialEntry;
14
+ steps: StepEntry[];
15
+ currentStep: StepEntry;
16
+ aiDefaults: AiConfig;
17
+ siteName?: string;
18
+ tagline?: string;
19
+ logoUrl?: string;
20
+ faviconUrl?: string;
21
+ repoUrl?: string;
22
+ }
23
+
24
+ const {
25
+ tutorial,
26
+ steps,
27
+ currentStep,
28
+ aiDefaults,
29
+ siteName,
30
+ tagline,
31
+ logoUrl,
32
+ faviconUrl,
33
+ repoUrl,
34
+ } = Astro.props;
35
+ const { stepSlug } = parseStepId(currentStep.id);
36
+ const { Content } = await render(currentStep);
37
+
38
+ const components = mdxComponents();
39
+ const hasCheckpoint = currentStep.body?.includes("<Checkpoint") ?? false;
40
+
41
+ const aiConfig: AiConfig = { ...aiDefaults, ...(tutorial.data.ai ?? {}) };
42
+ const initialContext = buildContext({
43
+ tutorial,
44
+ steps,
45
+ currentStep,
46
+ progress: emptyState(),
47
+ references: [],
48
+ includeFutureSteps: aiConfig.includeFutureSteps,
49
+ });
50
+ ---
51
+ <TutorialLayout
52
+ tutorial={tutorial}
53
+ steps={steps}
54
+ currentStep={currentStep}
55
+ currentStepSlug={stepSlug}
56
+ hasCheckpoint={hasCheckpoint}
57
+ siteName={siteName}
58
+ tagline={tagline}
59
+ logoUrl={logoUrl}
60
+ faviconUrl={faviconUrl}
61
+ repoUrl={repoUrl}
62
+ >
63
+ <Content components={components} />
64
+ {aiConfig.enabled && (
65
+ <ChatButton client:idle config={aiConfig} context={initialContext} />
66
+ )}
67
+ </TutorialLayout>
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Static-path helpers for handzon scaffolds. Use these as the
3
+ * `getStaticPaths` export of a route file:
4
+ *
5
+ * export const getStaticPaths = getTutorialLandingPaths;
6
+ * export const getStaticPaths = getTutorialStepPaths;
7
+ */
8
+ import { getStepsForTutorial, getTutorials, parseStepId } from "../lib/content.ts";
9
+ import type { StepEntry, TutorialEntry } from "../lib/content.ts";
10
+
11
+ export async function getTutorialLandingPaths() {
12
+ const tutorials = await getTutorials();
13
+ return tutorials.map((tut) => ({
14
+ params: { tutorial: tut.id },
15
+ props: { tutorial: tut },
16
+ }));
17
+ }
18
+
19
+ export async function getTutorialStepPaths() {
20
+ const tutorials = await getTutorials();
21
+ const paths: Array<{
22
+ params: { tutorial: string; step: string };
23
+ props: { tutorial: TutorialEntry; steps: StepEntry[]; currentStep: StepEntry };
24
+ }> = [];
25
+ for (const tut of tutorials) {
26
+ const steps = await getStepsForTutorial(tut.id);
27
+ for (const step of steps) {
28
+ const { stepSlug } = parseStepId(step.id);
29
+ paths.push({
30
+ params: { tutorial: tut.id, step: stepSlug },
31
+ props: { tutorial: tut, steps, currentStep: step },
32
+ });
33
+ }
34
+ }
35
+ return paths;
36
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Auth.js config factory consumed by the scaffold's `auth.config.ts`.
3
+ *
4
+ * Usage (scaffold side):
5
+ *
6
+ * import { createAuthConfig } from "handzon-core/server/auth/config";
7
+ * import { getDb } from "handzon-core/server/db/client";
8
+ * export default createAuthConfig({ db: getDb() });
9
+ *
10
+ * GitHub is the only provider in 0.2; email/password and others are
11
+ * out of scope (see plan: github-auth_d52529d5).
12
+ */
13
+ import { DrizzleAdapter } from "@auth/drizzle-adapter";
14
+ import GitHub from "@auth/core/providers/github";
15
+ import { defineConfig } from "auth-astro";
16
+ import { accounts, sessions, users, verificationTokens } from "./schema.ts";
17
+
18
+ interface AuthConfigOptions {
19
+ /**
20
+ * Postgres drizzle client, or `null` when DATABASE_URL isn't
21
+ * available at build time. When `null`, we return a stub config so
22
+ * auth-astro's integration loads but does nothing — useful for Tier 1
23
+ * builds and for `pnpm build` on a fresh checkout without a database.
24
+ */
25
+ db: Parameters<typeof DrizzleAdapter>[0] | null;
26
+ }
27
+
28
+ /**
29
+ * Normalises whatever the operator gave us into a full URL that
30
+ * Auth.js + GitHub OAuth callbacks can use. Priority:
31
+ *
32
+ * 1. `AUTH_URL` — operator override; supports either a
33
+ * full URL or a bare hostname (in which
34
+ * case we append `.onrender.com`, the
35
+ * same shape used by ALLOWED_ORIGIN and
36
+ * PUBLIC_AI_SERVICE_URL).
37
+ * 2. `RENDER_EXTERNAL_URL` — Render auto-injects this on every web
38
+ * service (e.g. `https://foo.onrender.com`).
39
+ * 3. `RENDER_EXTERNAL_HOSTNAME` — bare hostname, also auto-injected;
40
+ * we prefix with `https://`.
41
+ *
42
+ * Returns `undefined` when none are set (local dev with no override —
43
+ * Auth.js falls back to the request's Origin header).
44
+ */
45
+ function resolveAuthUrl(): string | undefined {
46
+ const explicit = process.env.AUTH_URL?.trim();
47
+ if (explicit) {
48
+ const trimmed = explicit.replace(/\/$/, "");
49
+ return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}.onrender.com`;
50
+ }
51
+ const renderUrl = process.env.RENDER_EXTERNAL_URL?.trim();
52
+ if (renderUrl) return renderUrl.replace(/\/$/, "");
53
+ const renderHost = process.env.RENDER_EXTERNAL_HOSTNAME?.trim();
54
+ if (renderHost) return `https://${renderHost}`;
55
+ return undefined;
56
+ }
57
+
58
+ export function createAuthConfig({ db }: AuthConfigOptions) {
59
+ // Auth.js v5 reads `AUTH_URL` from process.env on each request, so
60
+ // we resolve once and write it back. Idempotent: if AUTH_URL was
61
+ // already a full URL, this is a no-op aside from the trailing-slash
62
+ // trim.
63
+ const resolvedUrl = resolveAuthUrl();
64
+ if (resolvedUrl && resolvedUrl !== process.env.AUTH_URL) {
65
+ process.env.AUTH_URL = resolvedUrl;
66
+ }
67
+
68
+ if (!db) {
69
+ // No database → no adapter → no providers. auth-astro logs a warning
70
+ // and any sign-in attempt fails gracefully instead of crashing the
71
+ // build.
72
+ return defineConfig({ providers: [] });
73
+ }
74
+ return defineConfig({
75
+ adapter: DrizzleAdapter(db, {
76
+ usersTable: users,
77
+ accountsTable: accounts,
78
+ sessionsTable: sessions,
79
+ verificationTokensTable: verificationTokens,
80
+ }),
81
+ providers: [
82
+ GitHub({
83
+ clientId: process.env.GITHUB_CLIENT_ID,
84
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
85
+ }),
86
+ ],
87
+ // JWT session strategy keeps the cookie self-describing — no extra
88
+ // DB hit on every request. The `users` table still holds the
89
+ // canonical record; we just don't store the live session there.
90
+ session: { strategy: "jwt" },
91
+ callbacks: {
92
+ // Surface the `users.id` UUID on `session.user.id` so server
93
+ // code (getOrCreateLearner) can match it without re-resolving.
94
+ async session({ session, token }) {
95
+ if (session.user && token.sub) {
96
+ (session.user as { id?: string }).id = token.sub;
97
+ }
98
+ return session;
99
+ },
100
+ },
101
+ });
102
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Auth.js (NextAuth) drizzle schema for Postgres. Tables shape matches
3
+ * the canonical reference in `@auth/drizzle-adapter` docs. Drizzle
4
+ * passes these to `DrizzleAdapter(db)` via the adapter's auto-detection.
5
+ *
6
+ * The `learners` row that maps a signed-in user to local progress lives
7
+ * in `../db/schema.ts` — it adds a nullable `user_id` FK to `users` here.
8
+ */
9
+ import {
10
+ integer,
11
+ pgTable,
12
+ primaryKey,
13
+ text,
14
+ timestamp,
15
+ uuid,
16
+ } from "drizzle-orm/pg-core";
17
+
18
+ export const users = pgTable("users", {
19
+ id: uuid("id").primaryKey().defaultRandom(),
20
+ name: text("name"),
21
+ email: text("email").notNull().unique(),
22
+ emailVerified: timestamp("email_verified", { withTimezone: true }),
23
+ image: text("image"),
24
+ });
25
+
26
+ export const accounts = pgTable(
27
+ "accounts",
28
+ {
29
+ userId: uuid("user_id")
30
+ .notNull()
31
+ .references(() => users.id, { onDelete: "cascade" }),
32
+ type: text("type").notNull(),
33
+ provider: text("provider").notNull(),
34
+ providerAccountId: text("provider_account_id").notNull(),
35
+ refresh_token: text("refresh_token"),
36
+ access_token: text("access_token"),
37
+ expires_at: integer("expires_at"),
38
+ token_type: text("token_type"),
39
+ scope: text("scope"),
40
+ id_token: text("id_token"),
41
+ session_state: text("session_state"),
42
+ },
43
+ (account) => ({
44
+ pk: primaryKey({ columns: [account.provider, account.providerAccountId] }),
45
+ }),
46
+ );
47
+
48
+ export const sessions = pgTable("sessions", {
49
+ sessionToken: text("session_token").primaryKey(),
50
+ userId: uuid("user_id")
51
+ .notNull()
52
+ .references(() => users.id, { onDelete: "cascade" }),
53
+ expires: timestamp("expires", { withTimezone: true }).notNull(),
54
+ });
55
+
56
+ export const verificationTokens = pgTable(
57
+ "verification_tokens",
58
+ {
59
+ identifier: text("identifier").notNull(),
60
+ token: text("token").notNull(),
61
+ expires: timestamp("expires", { withTimezone: true }).notNull(),
62
+ },
63
+ (vt) => ({
64
+ pk: primaryKey({ columns: [vt.identifier, vt.token] }),
65
+ }),
66
+ );
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Thin wrapper around `auth-astro/server`'s `getSession` that flattens
3
+ * the shape into something boring and stable. Callers don't need to
4
+ * know about Auth.js's JWT vs database session details.
5
+ */
6
+ import { getSession } from "auth-astro/server";
7
+
8
+ export interface AuthedUser {
9
+ userId: string;
10
+ email: string | null;
11
+ name: string | null;
12
+ image: string | null;
13
+ }
14
+
15
+ export async function getAuthedUser(request: Request): Promise<AuthedUser | null> {
16
+ const session = await getSession(request);
17
+ const user = session?.user as
18
+ | { id?: string; email?: string | null; name?: string | null; image?: string | null }
19
+ | undefined;
20
+ if (!user?.id) return null;
21
+ return {
22
+ userId: user.id,
23
+ email: user.email ?? null,
24
+ name: user.name ?? null,
25
+ image: user.image ?? null,
26
+ };
27
+ }