Table of Contents
- Plan: Promoter Page
- Context
- Progress
- 1. Promo Auth Utilities (src/lib/server/auth.ts) ✅
- 2. app.d.ts ✅
- 3. Hooks (src/hooks.server.ts) ✅
- 4. Zod Types 🔲
- 5. Audio Cache Module (src/lib/server/audio-cache.ts) 🔲
- 6. Feature: Promoter Page (/promo) 🔲
- Route structure
- +page.server.ts — load collections + tracks (TODO)
- track/[trackId]/+server.ts — cached audio proxy with Range support (TODO)
- 7. Admin: Demo Collections & Tracks (/admin/demos) 🔲
- src/routes/admin/(dashboard)/demos/+page.server.ts
- src/routes/admin/(dashboard)/demos/+page.svelte
- src/lib/components/forms/demo-collection-form.svelte
- src/lib/components/forms/demo-track-form.svelte
- 8. Admin: Settings (/admin/settings) ✅
- src/routes/admin/(dashboard)/settings/+page.server.ts
- src/routes/admin/(dashboard)/settings/+page.svelte
- 9. Admin Sidebar (src/lib/components/routes/admin/admin-sidebar.svelte) ✅
- 10. S3 Helper Additions (src/lib/server/s3.ts) 🔲
- 11. Env Var 🔲
- File Manifest
- Verification (for remaining items)
- Lessons Learned
Plan: Promoter Page
Context
The Dancaestral website needs a password-protected promoter page (/promo) serving unreleased demo tracks privately. The demoCollection, demoTrack, and appSetting tables already exist in the schema (PostgreSQL, Drizzle). Audio files are stored in S3-compatible object storage via Bun's native S3Client. The admin dashboard already has Events and Albums CRUD — the demos and settings admin pages need to be added.
Progress
✅ Completed
- Section 1 — Promo Auth Utilities (
src/lib/server/auth.ts) - Section 2 —
app.d.ts(promoAuthed: booleanadded toApp.Locals) - Section 3 — Hooks (
handlePromoadded tosequence(...)) - Section 6 (partial) — Promo routes: layout, login page, login server, auth guard all created.
+page.sveltehas dummy text only (collections + players not yet built) - Section 7 (partial) — Empty placeholder page at
/admin/demos(no CRUD yet) - Section 8 — Admin Settings page (full: password field, copy current password, warning, upsert)
- Section 9 — Admin sidebar (Demo + Impostazioni nav items added)
🔲 Remaining
- Section 4 — Zod types: only
promoLoginSchemaandpromoPasswordSchemaexist. Still need:demoCollectionSchema,updateDemoCollectionSchema,demoTrackSchema,updateDemoTrackSchema - Section 5 — Audio Cache Module (
src/lib/server/audio-cache.ts) - Section 6 — Promo
+page.svelte(replace dummy text with collections + audio players) - Section 6 — Promo
+page.server.ts(load collections + tracks) - Section 6 —
track/[trackId]/+server.ts(cached audio proxy with Range support) - Section 7 — Admin demos full CRUD (
+page.server.ts,+page.svelte, collection/track forms, S3 upload, cache invalidation) - Section 10 — S3 Helper Additions (
getS3ClientInstance,deleteS3Object) - Section 11 — Env var
AUDIO_CACHE_DIR
1. Promo Auth Utilities (src/lib/server/auth.ts) ✅
Add alongside existing cookie helpers (uses @oslojs/crypto/sha2 + @oslojs/encoding already imported):
export const promoCookieName = 'promo-session';
export function getPromoPasswordHash(password: string): string {
return encodeHexLowerCase(sha256(new TextEncoder().encode(password)));
}
export function setPromoCookie(event: RequestEvent, hash: string): void {
event.cookies.set(promoCookieName, hash, {
httpOnly: true,
secure: true,
path: '/promo',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7
});
}
export function deletePromoCookie(event: RequestEvent): void {
event.cookies.delete(promoCookieName, { path: '/promo' });
}
Password change in admin immediately invalidates all promo cookies (hash changes → stored cookie no longer matches).
2. app.d.ts ✅
Add promoAuthed: boolean; to App.Locals:
interface Locals {
user: import('$lib/server/auth').SessionValidationResult['user'];
session: import('$lib/server/auth').SessionValidationResult['session'];
promoAuthed: boolean;
}
3. Hooks (src/hooks.server.ts) ✅
Add handlePromo handle and include it in sequence(handleParaglide, handleAuth, handlePromo):
const handlePromo: Handle = async ({ event, resolve }) => {
event.locals.promoAuthed = false;
if (!event.url.pathname.startsWith('/promo')) return resolve(event);
const cookie = event.cookies.get(auth.promoCookieName);
if (!cookie) return resolve(event);
const [setting] = await db
.select()
.from(table.appSettingTable)
.where(eq(table.appSettingTable.key, 'promo_password'));
if (setting) {
event.locals.promoAuthed =
cookie === auth.getPromoPasswordHash(setting.value);
}
return resolve(event);
};
The hook reads the promo password from appSettingTable, hashes it, and compares against the cookie value.
4. Zod Types 🔲
src/lib/types/demo.ts — partial (only promoLoginSchema + promoPasswordSchema). Still need collection and track schemas:
// TODO: Add these
export const demoCollectionSchema = z.object({
title: z.string().min(1),
description_it: z.string().optional(),
description_en: z.string().optional(),
order: z.coerce.number().int().default(0)
});
export const updateDemoCollectionSchema = demoCollectionSchema.extend({
id: z.coerce.number().int().positive()
});
export const demoTrackSchema = z.object({
collectionId: z.coerce.number().int().positive(),
title: z.string().min(1),
description_it: z.string().optional(),
description_en: z.string().optional(),
order: z.coerce.number().int().default(0)
});
export const updateDemoTrackSchema = demoTrackSchema.extend({
id: z.coerce.number().int().positive()
});
5. Audio Cache Module (src/lib/server/audio-cache.ts) 🔲
Browsers send Range requests for every audio seek, so streaming every chunk from S3 means many read requests per track play. Instead, we download the full file from S3 once on first request, cache it to the local filesystem, and serve all subsequent requests (including Range) directly from disk.
src/lib/server/audio-cache.ts
import { existsSync, mkdirSync, statSync, unlinkSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { createWriteStream } from 'node:fs';
import { open } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { getS3ClientInstance } from './s3.js';
const CACHE_DIR = process.env.AUDIO_CACHE_DIR ?? '/var/cache/dancaestral/audio';
function ensureCacheDir(): void {
if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true });
}
/** Maps an S3 key to a local filesystem path inside the cache dir. */
export function cachedFilePath(storagePath: string): string {
return join(CACHE_DIR, storagePath);
}
export function isCached(storagePath: string): boolean {
return existsSync(cachedFilePath(storagePath));
}
export function getCachedSize(storagePath: string): number | null {
if (!isCached(storagePath)) return null;
return statSync(cachedFilePath(storagePath)).size;
}
/** Download full file from S3 and write to cache dir. Returns file size. */
export async function downloadToCache(storagePath: string): Promise<number> {
const s3 = getS3ClientInstance();
if (!s3) throw new Error('S3 not configured');
const file = s3.file(storagePath);
if (!(await file.exists())) throw new Error(`S3 object not found: ${storagePath}`);
ensureCacheDir();
const dest = cachedFilePath(storagePath);
mkdirSync(dirname(dest), { recursive: true });
const blob = await file.blob();
const nodeStream = Readable.fromWeb(blob.stream() as any);
await pipeline(nodeStream, createWriteStream(dest));
return statSync(dest).size;
}
/** Open a cached file and return a ReadableStream for a byte range (or full file). */
export async function openCachedRange(
storagePath: string,
start?: number,
end?: number
): Promise<{ stream: ReadableStream; size: number }> {
const filePath = cachedFilePath(storagePath);
const handle = await open(filePath, 'r');
const { size } = await handle.stat();
const rangeStart = start ?? 0;
const rangeEnd = end ?? size - 1;
const nodeStream = handle.createReadStream({ start: rangeStart, end: rangeEnd });
nodeStream.on('close', () => handle.close());
return {
stream: Readable.toWeb(nodeStream) as ReadableStream,
size
};
}
/** Remove a cached file (called on track deletion or re-upload). */
export function invalidateCache(storagePath: string): void {
const path = cachedFilePath(storagePath);
if (existsSync(path)) unlinkSync(path);
}
How it works
- First request for a track →
isCached()returns false →downloadToCache()fetches the full file from S3 and writes it to/var/cache/dancaestral/audio/{storagePath}. - All requests (first included, after download) serve from disk via
openCachedRange(), which usesfs.createReadStream({ start, end })for efficient Range responses — zero S3 calls. - Subsequent Range requests from browser seeks → served directly from the local file descriptor, near-instant.
- Track deletion / re-upload →
invalidateCache(storagePath)removes the local file so the next request re-downloads from S3.
6. Feature: Promoter Page (/promo) 🔲
Route structure
src/routes/promo/
+layout.server.ts ← ✅ redirect to /promo/login if !promoAuthed
+layout.svelte ← ✅ minimal layout (no main Navbar/footer)
+page.svelte ← 🔲 DUMMY TEXT — needs collections + audio players
+page.server.ts ← 🔲 load collections + tracks
login/
+page.svelte ← ✅ password form
+page.server.ts ← ✅ validate → set cookie → redirect to /promo
track/
[trackId]/
+server.ts ← 🔲 S3 audio proxy (validates promoAuthed, streams with Range support)
+page.server.ts — load collections + tracks (TODO)
import { db } from '$lib/server/db/index.js';
import { demoCollectionTable, demoTrackTable } from '$lib/server/db/schema.js';
import { asc } from 'drizzle-orm';
export async function load() {
const collections = await db
.select()
.from(demoCollectionTable)
.orderBy(asc(demoCollectionTable.order));
const tracks = await db
.select()
.from(demoTrackTable)
.orderBy(asc(demoTrackTable.order));
const grouped = collections.map((collection) => ({
...collection,
tracks: tracks.filter((t) => t.collectionId === collection.id)
}));
return { collections: grouped };
}
track/[trackId]/+server.ts — cached audio proxy with Range support (TODO)
Serves audio from the local filesystem cache, downloading from S3 on first access. All Range requests are served from disk — one S3 read per track lifetime (plus re-uploads).
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { demoTrackTable } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import {
isCached,
downloadToCache,
getCachedSize,
openCachedRange
} from '$lib/server/audio-cache.js';
export const GET: RequestHandler = async (event) => {
if (!event.locals.promoAuthed) {
return new Response('Unauthorized', { status: 401 });
}
const trackId = parseInt(event.params.trackId);
const [track] = await db
.select()
.from(demoTrackTable)
.where(eq(demoTrackTable.id, trackId));
if (!track) {
return new Response('Not Found', { status: 404 });
}
// Ensure the file is cached locally (download from S3 if not)
try {
if (!isCached(track.storagePath)) {
await downloadToCache(track.storagePath);
}
} catch {
return new Response('File Not Found', { status: 404 });
}
const totalSize = getCachedSize(track.storagePath)!;
const rangeHeader = event.request.headers.get('Range');
if (rangeHeader) {
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (!match) return new Response('Invalid Range', { status: 416 });
const start = parseInt(match[1]);
const end = match[2] ? parseInt(match[2]) : totalSize - 1;
const chunkSize = end - start + 1;
const { stream } = await openCachedRange(track.storagePath, start, end);
return new Response(stream, {
status: 206,
headers: {
'Content-Type': 'audio/mpeg',
'Content-Length': String(chunkSize),
'Content-Range': `bytes ${start}-${end}/${totalSize}`,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store'
}
});
}
const { stream } = await openCachedRange(track.storagePath);
return new Response(stream, {
headers: {
'Content-Type': 'audio/mpeg',
'Content-Length': String(totalSize),
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store'
}
});
};
Range support is required — browsers send Range: headers for audio seeks and will stall without 206 responses.
7. Admin: Demo Collections & Tracks (/admin/demos) 🔲
src/routes/admin/(dashboard)/demos/+page.server.ts
load: all collections (ordered byorder) + all tracks. Superforms:createCollection,editCollection,deleteCollection,createTrack,deleteTrack.- Action
createTrack: handles file upload viarequest.formData(). File is uploaded to S3 at keydemos/{collectionId}/{timestamp}-{sanitized-filename}using the presigned URL flow fromsrc/lib/server/s3.ts+src/routes/admin/(dashboard)/api/upload/+server.ts. The S3 key is stored asstoragePath. - Action
deleteTrack: deletes DB row, deletes the S3 object viadeleteS3Object()insrc/lib/server/s3.ts, and callsinvalidateCache(storagePath)to remove the local cached copy.
src/routes/admin/(dashboard)/demos/+page.svelte
- Two-column or accordion layout: collections on left, tracks for selected collection on right.
- Dialogs for collection CRUD.
- Track form includes
<input type="file">for audio upload. - Show a preview audio player per track (disabled until password is set in settings).
src/lib/components/forms/demo-collection-form.svelte
Shared form with: title, description (it/en), order.
src/lib/components/forms/demo-track-form.svelte
Shared form with: title, description (it/en), order, file input.
8. Admin: Settings (/admin/settings) ✅
src/routes/admin/(dashboard)/settings/+page.server.ts
load: returns form + current password value (if set).- Action
default: upsert intoappSettingTableusing.onConflictDoUpdate({ target: appSettingTable.key, set: { value: sqlexcluded.value} }).
src/routes/admin/(dashboard)/settings/+page.svelte
- Single card showing current password (with copy button) above the warning.
- Password field (min 8 chars) + save button.
- Shows a warning that changing the password will invalidate all existing promo sessions.
- Success message after save.
9. Admin Sidebar (src/lib/components/routes/admin/admin-sidebar.svelte) ✅
Added to items array:
{ title: 'Demo', url: '/admin/demos', icon: MicVocalIcon },
{ title: 'Impostazioni', url: '/admin/settings', icon: SettingsIcon }
10. S3 Helper Additions (src/lib/server/s3.ts) 🔲
Add getS3ClientInstance (internal, used by audio-cache) and deleteS3Object for track file cleanup:
export function getS3ClientInstance() {
const config = getS3Client();
if (!config) return null;
return config.s3;
}
export async function deleteS3Object(key: string): Promise<boolean> {
const s3 = getS3ClientInstance();
if (!s3) return false;
try {
await s3.delete(key);
return true;
} catch {
return false;
}
}
11. Env Var 🔲
Add AUDIO_CACHE_DIR to the server environment (e.g. in .env):
AUDIO_CACHE_DIR=/var/cache/dancaestral/audio
Defaults to /var/cache/dancaestral/audio if not set. The directory is created automatically on first download.
File Manifest
✅ Done
| File | Change |
|---|---|
src/app.d.ts |
Add promoAuthed: boolean to App.Locals |
src/hooks.server.ts |
Add handlePromo to sequence(...) + db/table imports |
src/lib/server/auth.ts |
Add promoCookieName, getPromoPasswordHash, setPromoCookie, deletePromoCookie |
src/lib/types/demo.ts |
Zod schemas for promo login + promo password |
src/lib/components/routes/admin/admin-sidebar.svelte |
Add Demo + Impostazioni nav items |
src/routes/admin/(dashboard)/settings/+page.server.ts |
New — promo password upsert |
src/routes/admin/(dashboard)/settings/+page.svelte |
New — settings UI (current password + copy + form + warning) |
src/routes/admin/(dashboard)/demos/+page.svelte |
New — empty placeholder page |
src/routes/promo/+layout.server.ts |
New — promo auth guard |
src/routes/promo/+layout.svelte |
New — minimal promo layout |
src/routes/promo/+page.svelte |
New — dummy text placeholder |
src/routes/promo/login/+page.svelte |
New — password form |
src/routes/promo/login/+page.server.ts |
New — validate password, set cookie |
🔲 Still to create
| File | Purpose |
|---|---|
src/lib/server/audio-cache.ts |
Filesystem cache layer for S3 audio files |
src/lib/components/forms/demo-collection-form.svelte |
Collection form |
src/lib/components/forms/demo-track-form.svelte |
Track form with file input |
src/routes/admin/(dashboard)/demos/+page.server.ts |
Demos CRUD + S3 upload server |
src/routes/promo/+page.svelte |
Replace dummy text with collections + audio players |
src/routes/promo/+page.server.ts |
Load collections + tracks |
src/routes/promo/track/[trackId]/+server.ts |
Cached audio proxy with Range support |
Verification (for remaining items)
- Promo login: set password in
/admin/settings. Hit/promo→ confirm redirect to/promo/login. Wrong password → error message. Correct password → redirect to/promo. - Cookie invalidation: log into
/promo, change password in admin, refresh → confirm redirect to login. - Demo upload: upload audio track via
/admin/demos. Verify file appears in S3 atdemos/{collectionId}/.... - Audio cache: play track on
/promo— first request downloads from S3 to/var/cache/dancaestral/audio/.... Subsequent requests (including seeks) served from disk. Verify withlson cache dir + DevTools Network showing 206 withContent-Range. - Unauthorized proxy: unauthenticated
GET /promo/track/1→ 401. - Track deletion + cache invalidation: delete a track in admin → verify DB row removed, S3 object deleted, and local cache file removed.
Lessons Learned
- superforms: Always add
nameattribute to form<input>elements when usingsuperForm— without it the browser doesn't send the field in POST data, causing 400 errors. - superforms: Use
() => data.form(getter function) instead ofdata.formdirectly insuperForm()to avoid Svelte 5's "captures only initial value" warning. - superforms: The correct property from
superForm()for loading state issubmitting(notenhancing). - superforms: Add
use:enhancedirective on<form>for proper client-side form handling with superforms.