@svgrid/create 2.7.0 → 2.8.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 (29) hide show
  1. package/index.mjs +5 -1
  2. package/package.json +2 -2
  3. package/templates/pivot-dashboard/README.md +74 -0
  4. package/templates/pivot-dashboard/_gitignore +23 -0
  5. package/templates/pivot-dashboard/_package.json +27 -0
  6. package/templates/pivot-dashboard/src/app.css +15 -0
  7. package/templates/pivot-dashboard/src/app.d.ts +11 -0
  8. package/templates/pivot-dashboard/src/app.html +22 -0
  9. package/templates/pivot-dashboard/src/lib/drill.ts +141 -0
  10. package/templates/pivot-dashboard/src/lib/facts.ts +81 -0
  11. package/templates/pivot-dashboard/src/lib/theme.svelte.ts +87 -0
  12. package/templates/pivot-dashboard/src/routes/+layout.svelte +79 -0
  13. package/templates/pivot-dashboard/src/routes/+page.server.ts +11 -0
  14. package/templates/pivot-dashboard/src/routes/+page.svelte +138 -0
  15. package/templates/pivot-dashboard/src/routes/DrillRail.svelte +82 -0
  16. package/templates/pivot-dashboard/src/routes/TrendChart.svelte +62 -0
  17. package/templates/pivot-dashboard/tsconfig.json +20 -0
  18. package/templates/pivot-dashboard/vite.config.ts +20 -0
  19. package/templates/sveltekit/README.md +33 -3
  20. package/templates/sveltekit/src/app.d.ts +10 -2
  21. package/templates/sveltekit/src/hooks.server.ts +49 -0
  22. package/templates/sveltekit/src/lib/server/auth.ts +177 -0
  23. package/templates/sveltekit/src/routes/+layout.server.ts +10 -0
  24. package/templates/sveltekit/src/routes/+layout.svelte +16 -1
  25. package/templates/sveltekit/src/routes/login/+page.server.ts +51 -0
  26. package/templates/sveltekit/src/routes/login/+page.svelte +55 -0
  27. package/templates/sveltekit/src/routes/logout/+server.ts +24 -0
  28. package/templates/sveltekit/src/routes/people/+page.server.ts +12 -3
  29. package/templates/sveltekit/src/routes/people/+page.svelte +10 -5
@@ -0,0 +1,138 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Pivot dashboard: one cube, one chart, one drill rail, all over the same
4
+ * facts.
5
+ *
6
+ * The point of the layout is that the three panes cannot disagree. The pivot
7
+ * summarises `facts`; the chart re-aggregates the same array along the first
8
+ * row dimension; the drill rail filters it back down to the rows behind one
9
+ * clicked cell. Change the layout in the designer and all three follow.
10
+ */
11
+ import { SvPivotDesigner, setLicenseKey, type PivotField, type PivotLayout, type PivotRow } from '@svgrid/enterprise'
12
+ import { drillThrough, type Drill } from '$lib/drill'
13
+ import type { Fact } from '$lib/facts'
14
+ import DrillRail from './DrillRail.svelte'
15
+ import TrendChart from './TrendChart.svelte'
16
+
17
+ // Swap for your own key. Unlicensed use still runs - it just nudges.
18
+ setLicenseKey('SVENTERPRISE-DEV-DEMO')
19
+
20
+ let { data } = $props()
21
+ const facts = $derived(data.facts as Fact[])
22
+
23
+ const fields: PivotField[] = [
24
+ { field: 'region', kind: 'dimension', label: 'Region' },
25
+ { field: 'country', kind: 'dimension', label: 'Country' },
26
+ { field: 'city', kind: 'dimension', label: 'City' },
27
+ { field: 'channel', kind: 'dimension', label: 'Channel' },
28
+ { field: 'customer', kind: 'dimension', label: 'Customer' },
29
+ { field: 'year', kind: 'dimension', label: 'Year' },
30
+ { field: 'quarter', kind: 'dimension', label: 'Quarter' },
31
+ { field: 'revenue', kind: 'measure', label: 'Revenue' },
32
+ { field: 'units', kind: 'measure', label: 'Units' },
33
+ ]
34
+
35
+ let layout = $state<PivotLayout>({
36
+ rows: ['region', 'country'],
37
+ cols: ['year'],
38
+ values: [{ field: 'revenue', agg: 'sum' }],
39
+ // Required by PivotLayout. Empty means every fact passes.
40
+ filters: [],
41
+ })
42
+
43
+ // The designer hands back the rows it rendered. Drill-through needs them to
44
+ // walk a clicked row up to its ancestors.
45
+ let pivotRows = $state<PivotRow[]>([])
46
+ let drill = $state<Drill | null>(null)
47
+
48
+ const money = (n: number) => n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
49
+ const plain = (n: number) => n.toLocaleString()
50
+ const measure = $derived(layout.values[0]?.field ?? 'revenue')
51
+ const format = $derived(measure === 'units' ? plain : money)
52
+
53
+ /** Chart series: the active measure totalled by the first row dimension. */
54
+ const series = $derived.by(() => {
55
+ const dimension = layout.rows[0]
56
+ if (!dimension) return []
57
+ const totals = new Map<string, number>()
58
+ for (const fact of facts) {
59
+ const key = String((fact as unknown as Record<string, unknown>)[dimension])
60
+ const value = Number((fact as unknown as Record<string, unknown>)[measure] ?? 0)
61
+ totals.set(key, (totals.get(key) ?? 0) + value)
62
+ }
63
+ return [...totals].map(([label, value]) => ({ label, value })).sort((a, b) => b.value - a.value)
64
+ })
65
+
66
+ /** The chart highlights whichever top-level value the drill is inside. */
67
+ const activeBar = $derived(drill && layout.rows[0] ? (drill.filter[layout.rows[0]] ?? null) : null)
68
+
69
+ function onCellClick(event: { row: PivotRow; columnId: string }) {
70
+ // The row-header column is a label, not an aggregation - nothing to drill.
71
+ if (event.columnId === '__pivotRowHeader') return
72
+ drill = drillThrough(facts, event.row, event.columnId, pivotRows, layout)
73
+ }
74
+
75
+ /** Clicking a bar drills the whole of that dimension value. */
76
+ function onBarSelect(label: string) {
77
+ const dimension = layout.rows[0]
78
+ if (!dimension) return
79
+ const matched = facts.filter((f) => String((f as unknown as Record<string, unknown>)[dimension]) === label)
80
+ drill = {
81
+ rowLabel: label,
82
+ filter: { [dimension]: label },
83
+ facts: matched,
84
+ measure,
85
+ total: matched.reduce((sum, f) => sum + Number((f as unknown as Record<string, unknown>)[measure] ?? 0), 0),
86
+ }
87
+ }
88
+ </script>
89
+
90
+ <svelte:head><title>Pivot dashboard</title></svelte:head>
91
+
92
+ <h1>Sales cube</h1>
93
+ <p class="lede">
94
+ {plain(facts.length)} facts, summarised on the server and pivoted in the browser.
95
+ Click any aggregated cell to see the rows behind it.
96
+ </p>
97
+
98
+ <div class="layout" class:has-drill={drill !== null}>
99
+ <section class="cube" aria-label="Pivot">
100
+ <SvPivotDesigner
101
+ data={facts}
102
+ {fields}
103
+ bind:layout
104
+ expandable
105
+ panelPosition="right"
106
+ onPivot={(rows: PivotRow[]) => (pivotRows = rows)}
107
+ {onCellClick}
108
+ />
109
+ </section>
110
+
111
+ <section class="chart" aria-label="Totals by {layout.rows[0] ?? 'dimension'}">
112
+ <h2>Total {measure} by {layout.rows[0] ?? 'dimension'}</h2>
113
+ <TrendChart {series} {format} active={activeBar} onSelect={onBarSelect} />
114
+ </section>
115
+
116
+ {#if drill}
117
+ <DrillRail {drill} {format} onClose={() => (drill = null)} />
118
+ {/if}
119
+ </div>
120
+
121
+ <style>
122
+ h1 { margin: 0 0 0.25rem; font-size: 1.4rem; }
123
+ .lede { margin: 0 0 1.25rem; color: var(--sg-muted, #64748b); }
124
+ .layout { display: grid; gap: 1rem; grid-template-columns: 1fr; }
125
+ .cube { min-width: 0; height: 460px; }
126
+ .chart {
127
+ min-width: 0; padding: 1rem;
128
+ border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
129
+ }
130
+ .chart h2 { margin: 0 0 0.75rem; font-size: 0.95rem; text-transform: capitalize; }
131
+ /* Side by side once there is room. Below this the panes stack, which keeps
132
+ the pivot usable on a phone instead of squeezing three columns in. */
133
+ @media (min-width: 60rem) {
134
+ .layout { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
135
+ .cube { grid-column: 1 / -1; }
136
+ .layout.has-drill .cube { grid-column: 1 / -1; }
137
+ }
138
+ </style>
@@ -0,0 +1,82 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The drill-through rail: the facts behind one clicked pivot cell.
4
+ *
5
+ * The KPI at the top is recomputed from the same rows the grid below lists,
6
+ * so it always matches the cell that was clicked. If it ever did not, the
7
+ * drill filter and the pivot aggregation would have drifted apart.
8
+ */
9
+ import { SvGrid, type GridColumns } from '@svgrid/grid'
10
+ import type { Drill } from '$lib/drill'
11
+ import { drillBreadcrumb } from '$lib/drill'
12
+ import type { Fact } from '$lib/facts'
13
+
14
+ type Props = {
15
+ drill: Drill
16
+ format: (value: number) => string
17
+ onClose: () => void
18
+ }
19
+
20
+ let { drill, format, onClose }: Props = $props()
21
+
22
+ const columns: GridColumns<Fact> = [
23
+ { field: 'customer', header: 'Customer' },
24
+ { field: 'city', header: 'City' },
25
+ { field: 'channel', header: 'Channel' },
26
+ { field: 'quarter', header: 'Qtr' },
27
+ { field: 'revenue', header: 'Revenue', align: 'right' },
28
+ { field: 'units', header: 'Units', align: 'right' },
29
+ ]
30
+
31
+ const average = $derived(drill.facts.length ? drill.total / drill.facts.length : 0)
32
+ </script>
33
+
34
+ <aside class="rail" aria-label="Drill-through">
35
+ <header>
36
+ <div>
37
+ <p class="eyebrow">Drill-through</p>
38
+ <h2>{drillBreadcrumb(drill)}</h2>
39
+ </div>
40
+ <button type="button" onclick={onClose} aria-label="Close drill-through">&times;</button>
41
+ </header>
42
+
43
+ <dl class="kpis">
44
+ <div><dt>{drill.measure}</dt><dd>{format(drill.total)}</dd></div>
45
+ <div><dt>Facts</dt><dd>{drill.facts.length.toLocaleString()}</dd></div>
46
+ <div><dt>Average</dt><dd>{format(Math.round(average))}</dd></div>
47
+ </dl>
48
+
49
+ <div class="grid">
50
+ <SvGrid data={drill.facts} {columns} sortable containerHeight={340} />
51
+ </div>
52
+ </aside>
53
+
54
+ <style>
55
+ .rail {
56
+ display: flex; flex-direction: column; gap: 1rem; min-width: 0;
57
+ border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
58
+ background: var(--sg-bg, #fff); padding: 1rem;
59
+ }
60
+ header { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
61
+ .eyebrow {
62
+ margin: 0 0 0.15rem; font-size: 0.6875rem; text-transform: uppercase;
63
+ letter-spacing: 0.06em; color: var(--sg-muted, #64748b);
64
+ }
65
+ h2 { margin: 0; font-size: 1rem; }
66
+ header button {
67
+ font: inherit; font-size: 1.25rem; line-height: 1; padding: 0.1rem 0.4rem; cursor: pointer;
68
+ background: none; border: 1px solid var(--sg-border, #e2e8f0);
69
+ border-radius: var(--sg-radius, 8px); color: var(--sg-fg, #0f172a);
70
+ }
71
+ .kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; margin: 0; }
72
+ .kpis div {
73
+ border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
74
+ padding: 0.5rem 0.6rem;
75
+ }
76
+ dt {
77
+ font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em;
78
+ color: var(--sg-muted, #64748b);
79
+ }
80
+ dd { margin: 0.15rem 0 0; font-size: 1rem; font-weight: 650; font-variant-numeric: tabular-nums; }
81
+ .grid { min-width: 0; }
82
+ </style>
@@ -0,0 +1,62 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A bar chart over whatever slice the dashboard is currently showing.
4
+ *
5
+ * Inline SVG on purpose: a starter should not pull a charting library in for
6
+ * one chart, and this way the bars are plain DOM you can style with the same
7
+ * `--sg-*` tokens as the grid.
8
+ */
9
+ type Props = {
10
+ /** Bars to draw, already aggregated. */
11
+ series: { label: string; value: number }[]
12
+ /** Formats the value in the tooltip and the axis. */
13
+ format: (value: number) => string
14
+ /** Highlighted bar, e.g. the one being drilled. */
15
+ active?: string | null
16
+ onSelect?: (label: string) => void
17
+ }
18
+
19
+ let { series, format, active = null, onSelect }: Props = $props()
20
+
21
+ const max = $derived(Math.max(1, ...series.map((s) => s.value)))
22
+ </script>
23
+
24
+ {#if series.length === 0}
25
+ <p class="empty">No data in this slice.</p>
26
+ {:else}
27
+ <ul class="bars">
28
+ {#each series as bar (bar.label)}
29
+ <li class="row" class:is-active={active === bar.label}>
30
+ <button
31
+ type="button"
32
+ onclick={() => onSelect?.(bar.label)}
33
+ title="{bar.label}: {format(bar.value)}"
34
+ >
35
+ <span class="label">{bar.label}</span>
36
+ <span class="track">
37
+ <!-- Width is the only inline style here: it is data, not design. -->
38
+ <span class="fill" style="width: {(bar.value / max) * 100}%"></span>
39
+ </span>
40
+ <span class="value">{format(bar.value)}</span>
41
+ </button>
42
+ </li>
43
+ {/each}
44
+ </ul>
45
+ {/if}
46
+
47
+ <style>
48
+ .bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
49
+ button {
50
+ display: grid; grid-template-columns: 8rem 1fr 6rem; align-items: center; gap: 0.75rem;
51
+ width: 100%; padding: 0.3rem 0.4rem; font: inherit; text-align: left;
52
+ background: none; border: 0; border-radius: var(--sg-radius, 8px); cursor: pointer;
53
+ color: var(--sg-fg, #0f172a);
54
+ }
55
+ button:hover { background: var(--sg-row-hover-bg, rgba(0, 0, 0, 0.04)); }
56
+ .row.is-active button { background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, transparent); }
57
+ .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.875rem; }
58
+ .track { background: var(--sg-border, #e2e8f0); border-radius: 999px; height: 0.7rem; overflow: hidden; }
59
+ .fill { display: block; height: 100%; background: var(--sg-accent, #2563eb); border-radius: 999px; }
60
+ .value { font-variant-numeric: tabular-nums; font-size: 0.8125rem; text-align: right; color: var(--sg-muted, #64748b); }
61
+ .empty { color: var(--sg-muted, #64748b); font-size: 0.875rem; margin: 0; }
62
+ </style>
@@ -0,0 +1,20 @@
1
+ {
2
+ "extends": "./.svelte-kit/tsconfig.json",
3
+ "compilerOptions": {
4
+ "rewriteRelativeImportExtensions": true,
5
+ "allowJs": true,
6
+ "checkJs": true,
7
+ "esModuleInterop": true,
8
+ "forceConsistentCasingInFileNames": true,
9
+ "resolveJsonModule": true,
10
+ "skipLibCheck": true,
11
+ "sourceMap": true,
12
+ "strict": true,
13
+ "moduleResolution": "bundler"
14
+ }
15
+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
16
+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
17
+ //
18
+ // To make changes to top-level options such as include and exclude, we recommend extending
19
+ // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
20
+ }
@@ -0,0 +1,20 @@
1
+ import adapter from '@sveltejs/adapter-auto';
2
+ import { sveltekit } from '@sveltejs/kit/vite';
3
+ import { defineConfig } from 'vite';
4
+
5
+ export default defineConfig({
6
+ plugins: [
7
+ sveltekit({
8
+ compilerOptions: {
9
+ // Force runes mode for the project, except for libraries. Can be removed in svelte 6.
10
+ runes: ({ filename }) =>
11
+ filename.split(/[/\\]/).includes('node_modules') ? undefined : true
12
+ },
13
+
14
+ // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
15
+ // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
16
+ // See https://svelte.dev/docs/kit/adapters for more information about adapters.
17
+ adapter: adapter()
18
+ })
19
+ ]
20
+ });
@@ -1,8 +1,8 @@
1
1
  # SvGrid + SvelteKit sample
2
2
 
3
- A grid whose rows are loaded on the server, sorted from the URL, and edited
4
- through a form action - the three things that are different about running a grid
5
- in SvelteKit rather than a plain Vite SPA.
3
+ A grid whose rows are loaded on the server, sorted from the URL, edited through
4
+ a form action, and gated behind a cookie session with roles - the things that
5
+ are different about running a grid in SvelteKit rather than a plain Vite SPA.
6
6
 
7
7
  ```bash
8
8
  npm install
@@ -19,6 +19,16 @@ npm run dev # http://localhost:5173/people
19
19
  light and dark, applied live.
20
20
  4. **`curl localhost:5173/people`.** The rows are in the HTML, not injected by
21
21
  JS afterwards. That is what a crawler sees.
22
+ 5. **Sign in as each user.** `admin@example.com` can edit names;
23
+ `viewer@example.com` cannot, and the column is not editable for them.
24
+ The password is `password` for both.
25
+ 6. **Try to bypass the gate.** Sign in as the viewer, then post the action by
26
+ hand. It comes back 403, because the check is on the server and hiding the
27
+ button was only cosmetic:
28
+
29
+ ```bash
30
+ curl -i -X POST 'localhost:5173/people?/rename' -F id=1 -F name=Nope
31
+ ```
22
32
 
23
33
  ## Where things are
24
34
 
@@ -27,9 +37,29 @@ npm run dev # http://localhost:5173/people
27
37
  | `src/lib/people.ts` | Stands in for your database. Swap for real queries. |
28
38
  | `src/routes/people/+page.server.ts` | `load` sorts from the query string; the `rename` action takes the edit. |
29
39
  | `src/routes/people/+page.svelte` | The grid. `externalSort` because the server owns the ordering. |
40
+ | `src/lib/server/auth.ts` | Password hashing (PBKDF2 via Web Crypto), sessions, roles. Swap the arrays for your database. |
41
+ | `src/hooks.server.ts` | Resolves the session once per request and gates routes from one list. |
42
+ | `src/routes/login/+page.server.ts` | Login action. Rejects open redirects; one message for every failure. |
43
+ | `src/routes/logout/+server.ts` | POST-only sign out. Drops the session server-side, not just the cookie. |
30
44
  | `src/lib/theme.svelte.ts` | Runtime theme switching via `resolveThemeTokens`. |
31
45
  | `src/app.css` | Imports one preset so the first paint is themed before JS runs. |
32
46
 
47
+ ## Auth
48
+
49
+ The scaffold is the real shape, with the storage stubbed:
50
+
51
+ - Passwords are hashed with PBKDF2-HMAC-SHA256 through Web Crypto, so the same
52
+ code runs on Node and on the edge runtimes `adapter-auto` may pick.
53
+ - The session cookie is `httpOnly` (an XSS bug cannot read it) and
54
+ `SameSite=Lax` (a cross-site POST cannot ride it).
55
+ - Sign out drops the session server-side, so a copied id stops working.
56
+ - Route gating lives in one list in `hooks.server.ts`, so adding a protected
57
+ route is an entry rather than a check you have to remember.
58
+
59
+ The users and sessions are in-memory, like `src/lib/people.ts`. Move both to
60
+ your database before this goes anywhere real - in particular, an in-memory
61
+ session map does not survive a restart or a second instance.
62
+
33
63
  ## Themes
34
64
 
35
65
  Pick a starting theme when you scaffold:
@@ -1,10 +1,18 @@
1
1
  // See https://svelte.dev/docs/kit/types#app.d.ts
2
2
  // for information about these interfaces
3
+ import type { User } from '$lib/server/auth'
4
+
3
5
  declare global {
4
6
  namespace App {
5
7
  // interface Error {}
6
- // interface Locals {}
7
- // interface PageData {}
8
+ interface Locals {
9
+ /** Set by hooks.server.ts on every request; null when signed out. */
10
+ user: User | null
11
+ }
12
+ interface PageData {
13
+ /** Returned by the root layout load, so every page can read it. */
14
+ user?: User | null
15
+ }
8
16
  // interface PageState {}
9
17
  // interface Platform {}
10
18
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Server hooks: resolve the session once per request, then gate routes.
3
+ *
4
+ * Doing it here rather than in each `+page.server.ts` is what makes the gate
5
+ * hard to forget - a new protected route is one entry in PROTECTED, not a
6
+ * check you have to remember to copy into every load function.
7
+ */
8
+ import { redirect, type Handle } from '@sveltejs/kit'
9
+ import { SESSION_COOKIE, userForSession } from '$lib/server/auth'
10
+ import type { Role } from '$lib/server/auth'
11
+
12
+ /**
13
+ * Route prefixes that need a session, and the role they need.
14
+ *
15
+ * `null` means any signed-in user. Longest prefix wins, so a specific child
16
+ * route can require more than its parent.
17
+ */
18
+ const PROTECTED: { prefix: string; role: Role | null }[] = [
19
+ { prefix: '/people', role: null },
20
+ { prefix: '/admin', role: 'admin' },
21
+ ]
22
+
23
+ function requirementFor(pathname: string): Role | null | undefined {
24
+ const match = PROTECTED.filter((r) => pathname === r.prefix || pathname.startsWith(r.prefix + '/')).sort(
25
+ (a, b) => b.prefix.length - a.prefix.length,
26
+ )[0]
27
+ return match ? match.role : undefined
28
+ }
29
+
30
+ export const handle: Handle = async ({ event, resolve }) => {
31
+ event.locals.user = await userForSession(event.cookies.get(SESSION_COOKIE))
32
+
33
+ const required = requirementFor(event.url.pathname)
34
+ if (required !== undefined) {
35
+ if (!event.locals.user) {
36
+ // Carry where they were going, so login can send them back rather than
37
+ // dumping everyone on the home page.
38
+ const from = encodeURIComponent(event.url.pathname + event.url.search)
39
+ redirect(303, `/login?redirectTo=${from}`)
40
+ }
41
+ if (required && event.locals.user.role !== required) {
42
+ // Signed in but not allowed: 403, not a redirect to login. Bouncing an
43
+ // authenticated user to a login form reads as a broken app.
44
+ redirect(303, '/people?error=forbidden')
45
+ }
46
+ }
47
+
48
+ return resolve(event)
49
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Auth scaffold: password hashing, sessions, and role lookup.
3
+ *
4
+ * Everything here runs on the server only - `$lib/server` is a folder SvelteKit
5
+ * enforces, so importing this from a component is a build error rather than a
6
+ * leaked password hash.
7
+ *
8
+ * Like `$lib/people.ts`, the stores are module-level and in-memory: they stand
9
+ * in for your database so the starter runs with no setup. Swap the two arrays
10
+ * for real queries and nothing else has to change. What is *not* a placeholder
11
+ * is the hashing and the cookie handling - those are the real patterns, because
12
+ * getting them wrong is the expensive kind of wrong.
13
+ *
14
+ * Built on Web Crypto rather than `node:crypto`, so the same code runs on Node,
15
+ * Deno, Bun and the edge runtimes `adapter-auto` may select. Nothing here needs
16
+ * a dependency.
17
+ */
18
+
19
+ export type Role = 'admin' | 'viewer'
20
+ export type User = { id: number; email: string; role: Role }
21
+
22
+ /** A user plus the credential we never hand to the client. */
23
+ type StoredUser = User & { passwordHash: string }
24
+
25
+ /** Name of the session cookie. */
26
+ export const SESSION_COOKIE = 'sid'
27
+
28
+ /** How long a session stays valid. Refreshed on each request. */
29
+ const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7 // 7 days
30
+
31
+ /** PBKDF2 rounds. OWASP's floor for PBKDF2-HMAC-SHA256 is 600k; this is the
32
+ * knob to raise as hardware gets faster. Stored alongside each hash so old
33
+ * hashes keep verifying after you raise it. */
34
+ const PBKDF2_ROUNDS = 600_000
35
+
36
+ const encoder = new TextEncoder()
37
+
38
+ function toHex(bytes: Uint8Array): string {
39
+ return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
40
+ }
41
+
42
+ function fromHex(hex: string): Uint8Array {
43
+ const out = new Uint8Array(hex.length / 2)
44
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
45
+ return out
46
+ }
47
+
48
+ async function derive(password: string, salt: Uint8Array, rounds: number): Promise<Uint8Array> {
49
+ const key = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits'])
50
+ const bits = await crypto.subtle.deriveBits(
51
+ { name: 'PBKDF2', salt: salt as BufferSource, iterations: rounds, hash: 'SHA-256' },
52
+ key,
53
+ 256,
54
+ )
55
+ return new Uint8Array(bits)
56
+ }
57
+
58
+ /** Format: rounds:salt:hash, so the work factor travels with the hash. */
59
+ async function hashPassword(password: string): Promise<string> {
60
+ const salt = crypto.getRandomValues(new Uint8Array(16))
61
+ const hash = await derive(password, salt, PBKDF2_ROUNDS)
62
+ return `${PBKDF2_ROUNDS}:${toHex(salt)}:${toHex(hash)}`
63
+ }
64
+
65
+ /** Compare without an early exit, so the time taken does not reveal how many
66
+ * leading bytes matched. */
67
+ function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
68
+ if (a.length !== b.length) return false
69
+ let diff = 0
70
+ for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!
71
+ return diff === 0
72
+ }
73
+
74
+ async function verifyPassword(password: string, stored: string): Promise<boolean> {
75
+ const [rounds, salt, hash] = stored.split(':')
76
+ if (!rounds || !salt || !hash) return false
77
+ const actual = await derive(password, fromHex(salt), Number(rounds))
78
+ return constantTimeEqual(actual, fromHex(hash))
79
+ }
80
+
81
+ // --- users -----------------------------------------------------------------
82
+
83
+ /**
84
+ * Seeded lazily: hashing is async, and 600k PBKDF2 rounds twice at import would
85
+ * stall the first request. `getUsers()` awaits the same promise every time, so
86
+ * the work happens once.
87
+ */
88
+ let usersPromise: Promise<StoredUser[]> | null = null
89
+
90
+ function getUsers(): Promise<StoredUser[]> {
91
+ usersPromise ??= (async () => [
92
+ { id: 1, email: 'admin@example.com', role: 'admin' as const, passwordHash: await hashPassword('password') },
93
+ { id: 2, email: 'viewer@example.com', role: 'viewer' as const, passwordHash: await hashPassword('password') },
94
+ ])()
95
+ return usersPromise
96
+ }
97
+
98
+ /** Hashed once so an unknown email costs the same work as a known one. Without
99
+ * it, a fast "no such user" reply enumerates your user list. */
100
+ let dummyHashPromise: Promise<string> | null = null
101
+ function getDummyHash(): Promise<string> {
102
+ dummyHashPromise ??= hashPassword(crypto.randomUUID())
103
+ return dummyHashPromise
104
+ }
105
+
106
+ /** Public shape - never includes the hash. */
107
+ function publicUser(u: StoredUser): User {
108
+ return { id: u.id, email: u.email, role: u.role }
109
+ }
110
+
111
+ /**
112
+ * Check an email/password pair. Returns the user, or null.
113
+ *
114
+ * The same null comes back for an unknown email and a wrong password, and a
115
+ * verify runs either way, so the response neither confirms which emails exist
116
+ * nor answers faster for one case than the other.
117
+ */
118
+ export async function verifyCredentials(email: string, password: string): Promise<User | null> {
119
+ const users = await getUsers()
120
+ const found = users.find((u) => u.email.toLowerCase() === email.trim().toLowerCase())
121
+ const ok = await verifyPassword(password, found?.passwordHash ?? (await getDummyHash()))
122
+ return ok && found ? publicUser(found) : null
123
+ }
124
+
125
+ // --- sessions --------------------------------------------------------------
126
+
127
+ type Session = { userId: number; expires: number }
128
+
129
+ const sessions = new Map<string, Session>()
130
+
131
+ /** Start a session and return its id, to be set as the cookie value. */
132
+ export function createSession(userId: number): string {
133
+ // randomUUID is a CSPRNG. A guessable session id is as good as no auth.
134
+ const id = crypto.randomUUID()
135
+ sessions.set(id, { userId, expires: Date.now() + SESSION_TTL_MS })
136
+ return id
137
+ }
138
+
139
+ /** Resolve a session id to its user, sliding the expiry forward. Returns null
140
+ * for an unknown or expired id, and drops the expired entry. */
141
+ export async function userForSession(id: string | undefined): Promise<User | null> {
142
+ if (!id) return null
143
+ const session = sessions.get(id)
144
+ if (!session) return null
145
+ if (session.expires < Date.now()) {
146
+ sessions.delete(id)
147
+ return null
148
+ }
149
+ session.expires = Date.now() + SESSION_TTL_MS
150
+ const users = await getUsers()
151
+ const user = users.find((u) => u.id === session.userId)
152
+ return user ? publicUser(user) : null
153
+ }
154
+
155
+ /** Invalidate a session server-side. Clearing the cookie alone would leave a
156
+ * stolen id working until it expired. */
157
+ export function destroySession(id: string | undefined): void {
158
+ if (id) sessions.delete(id)
159
+ }
160
+
161
+ /**
162
+ * Cookie options for the session.
163
+ *
164
+ * `httpOnly` keeps the id away from JavaScript, so an XSS bug cannot read it.
165
+ * `sameSite: 'lax'` blocks the cookie on cross-site POSTs, which is what stops
166
+ * CSRF against the form actions. `secure` is on outside dev, where there is no
167
+ * HTTPS to require.
168
+ */
169
+ export function sessionCookieOptions(secure: boolean) {
170
+ return {
171
+ path: '/',
172
+ httpOnly: true,
173
+ sameSite: 'lax',
174
+ secure,
175
+ maxAge: Math.floor(SESSION_TTL_MS / 1000),
176
+ } as const
177
+ }
@@ -0,0 +1,10 @@
1
+ import type { LayoutServerLoad } from './$types'
2
+
3
+ /**
4
+ * Publish the signed-in user to every page.
5
+ *
6
+ * `locals.user` is server-only; returning it from a layout load is what makes
7
+ * it available to components as `data.user`. Only the public shape crosses
8
+ * that boundary - `$lib/server/auth` never puts the password hash on it.
9
+ */
10
+ export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user })