···11+You are an advanced assistant specialized in generating Val Town code.
22+33+## Core Guidelines
44+55+- Ask clarifying questions when requirements are ambiguous
66+- Provide complete, functional solutions rather than skeleton implementations
77+- Test your logic against edge cases before presenting the final solution
88+- Ensure all code follows Val Town's specific platform requirements
99+- If a section of code that you're working on is getting too complex, consider refactoring it into subcomponents
1010+1111+## Code Standards
1212+1313+- Generate code in TypeScript or TSX
1414+- Add appropriate TypeScript types and interfaces for all data structures
1515+- Prefer official SDKs or libraries than writing API calls directly
1616+- Ask the user to supply API or library documentation if you are at all unsure about it
1717+- **Never bake in secrets into the code** - always use environment variables
1818+- Include comments explaining complex logic (avoid commenting obvious operations)
1919+- Follow modern ES6+ conventions and functional programming practices if possible
2020+2121+## Types of triggers
2222+2323+### 1. HTTP Trigger
2424+2525+- Create web APIs and endpoints
2626+- Handle HTTP requests and responses
2727+- Example structure:
2828+2929+```ts
3030+export default async function (req: Request) {
3131+ return new Response("Hello World");
3232+}
3333+```
3434+3535+Files that are HTTP triggers have http in their name like `foobar.http.tsx`
3636+3737+### 2. Cron Triggers
3838+3939+- Run on a schedule
4040+- Use cron expressions for timing
4141+- Example structure:
4242+4343+```ts
4444+export default async function () {
4545+ // Scheduled task code
4646+}
4747+```
4848+4949+Files that are Cron triggers have cron in their name like `foobar.cron.tsx`
5050+5151+### 3. Email Triggers
5252+5353+- Process incoming emails
5454+- Handle email-based workflows
5555+- Example structure:
5656+5757+```ts
5858+export default async function (email: Email) {
5959+ // Process email
6060+}
6161+```
6262+6363+Files that are Email triggers have email in their name like `foobar.email.tsx`
6464+6565+6666+## Val Town Standard Libraries
6767+6868+Val Town provides several hosted services and utility functions.
6969+7070+### Blob Storage
7171+7272+```ts
7373+import { blob } from "https://esm.town/v/std/blob";
7474+await blob.setJSON("myKey", { hello: "world" });
7575+let blobDemo = await blob.getJSON("myKey");
7676+let appKeys = await blob.list("app_");
7777+await blob.delete("myKey");
7878+```
7979+8080+### SQLite
8181+8282+```ts
8383+import { sqlite } from "https://esm.town/v/stevekrouse/sqlite";
8484+const TABLE_NAME = 'todo_app_users_2';
8585+// Create table - do this before usage and change table name when modifying schema
8686+await sqlite.execute(`CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
8787+ id INTEGER PRIMARY KEY AUTOINCREMENT,
8888+ name TEXT NOT NULL
8989+)`);
9090+// Query data
9191+const result = await sqlite.execute(`SELECT * FROM ${TABLE_NAME} WHERE id = ?`, [1]);
9292+```
9393+9494+Note: When changing a SQLite table's schema, change the table's name (e.g., add _2 or _3) to create a fresh table.
9595+9696+### OpenAI
9797+9898+```ts
9999+import { OpenAI } from "https://esm.town/v/std/openai";
100100+const openai = new OpenAI();
101101+const completion = await openai.chat.completions.create({
102102+ messages: [
103103+ { role: "user", content: "Say hello in a creative way" },
104104+ ],
105105+ model: "gpt-4o-mini",
106106+ max_tokens: 30,
107107+});
108108+```
109109+110110+### Email
111111+112112+```ts
113113+import { email } from "https://esm.town/v/std/email";
114114+// By default emails the owner of the val
115115+await email({
116116+ subject: "Hi",
117117+ text: "Hi",
118118+ html: "<h1>Hi</h1>"
119119+});
120120+```
121121+122122+## Val Town Utility Functions
123123+124124+Val Town provides several utility functions to help with common project tasks.
125125+126126+### Importing Utilities
127127+128128+Always import utilities with version pins to avoid breaking changes:
129129+130130+```ts
131131+import { parseProject, readFile, serveFile } from "https://esm.town/v/std/utils@85-main/index.ts";
132132+```
133133+134134+### Available Utilities
135135+136136+137137+#### **serveFile** - Serve project files with proper content types
138138+139139+For example, in Hono:
140140+141141+```ts
142142+// serve all files in frontend/ and shared/
143143+app.get("/frontend/*", c => serveFile(c.req.path, import.meta.url));
144144+app.get("/shared/*", c => serveFile(c.req.path, import.meta.url));
145145+```
146146+147147+#### **readFile** - Read files from within the project:
148148+149149+```ts
150150+// Read a file from the project
151151+const fileContent = await readFile("/frontend/index.html", import.meta.url);
152152+```
153153+154154+#### **listFiles** - List all files in the project
155155+156156+```ts
157157+const files = await listFiles(import.meta.url);
158158+```
159159+160160+#### **parseProject** - Extract information about the current project from import.meta.url
161161+162162+This is useful for including info for linking back to a val, ie in "view source" urls:
163163+164164+```ts
165165+const projectVal = parseProject(import.meta.url);
166166+console.log(projectVal.username); // Owner of the project
167167+console.log(projectVal.name); // Project name
168168+console.log(projectVal.version); // Version number
169169+console.log(projectVal.branch); // Branch name
170170+console.log(projectVal.links.self.project); // URL to the project page
171171+```
172172+173173+However, it's *extremely importing* to note that `parseProject` and other Standard Library utilities ONLY RUN ON THE SERVER.
174174+If you need access to this data on the client, run it in the server and pass it to the client by splicing it into the HTML page
175175+or by making an API request for it.
176176+177177+## Val Town Platform Specifics
178178+179179+- **Redirects:** Use `return new Response(null, { status: 302, headers: { Location: "/place/to/redirect" }})` instead of `Response.redirect` which is broken
180180+- **Images:** Avoid external images or base64 images. Use emojis, unicode symbols, or icon fonts/libraries instead
181181+- **AI Image:** To inline generate an AI image use: `<img src="https://maxm-imggenurl.web.val.run/the-description-of-your-image" />`
182182+- **Storage:** DO NOT use the Deno KV module for storage
183183+- **Browser APIs:** DO NOT use the `alert()`, `prompt()`, or `confirm()` methods
184184+- **Weather Data:** Use open-meteo for weather data (doesn't require API keys) unless otherwise specified
185185+- **View Source:** Add a view source link by importing & using `import.meta.url.replace("ems.sh", "val.town)"` (or passing this data to the client) and include `target="_top"` attribute
186186+- **Error Debugging:** Add `<script src="https://esm.town/v/std/catch"></script>` to HTML to capture client-side errors
187187+- **Error Handling:** Only use try...catch when there's a clear local resolution; Avoid catches that merely log or return 500s. Let errors bubble up with full context
188188+- **Environment Variables:** Use `Deno.env.get('keyname')` when you need to, but generally prefer APIs that don't require keys
189189+- **Imports:** Use `https://esm.sh` for npm and Deno dependencies to ensure compatibility on server and browser
190190+- **Storage Strategy:** Only use backend storage if explicitly required; prefer simple static client-side sites
191191+- **React Configuration:** When using React libraries, pin versions with `?deps=react@18.2.0,react-dom@18.2.0` and start the file with `/** @jsxImportSource https://esm.sh/react@18.2.0 */`
192192+- Ensure all React dependencies and sub-dependencies are pinned to the same version
193193+- **Styling:** Default to using TailwindCSS via `<script src="https://cdn.twind.style" crossorigin></script>` unless otherwise specified
194194+195195+## Project Structure and Design Patterns
196196+197197+### Recommended Directory Structure
198198+```
199199+├── backend/
200200+│ ├── database/
201201+│ │ ├── migrations.ts # Schema definitions
202202+│ │ ├── queries.ts # DB query functions
203203+│ │ └── README.md
204204+│ └── routes/ # Route modules
205205+│ ├── [route].ts
206206+│ └── static.ts # Static file serving
207207+│ ├── index.ts # Main entry point
208208+│ └── README.md
209209+├── frontend/
210210+│ ├── components/
211211+│ │ ├── App.tsx
212212+│ │ └── [Component].tsx
213213+│ ├── favicon.svg
214214+│ ├── index.html # Main HTML template
215215+│ ├── index.tsx # Frontend JS entry point
216216+│ ├── README.md
217217+│ └── style.css
218218+├── README.md
219219+└── shared/
220220+ ├── README.md
221221+ └── utils.ts # Shared types and functions
222222+```
223223+224224+### Backend (Hono) Best Practices
225225+226226+- Hono is the recommended API framework
227227+- Main entry point should be `backend/index.ts`
228228+- **Static asset serving:** Use the utility functions to read and serve project files:
229229+ ```ts
230230+ import { readFile, serveFile } from "https://esm.town/v/std/utils@85-main/index.ts";
231231+232232+ // serve all files in frontend/ and shared/
233233+ app.get("/frontend/*", c => serveFile(c.req.path, import.meta.url));
234234+ app.get("/shared/*", c => serveFile(c.req.path, import.meta.url));
235235+236236+ // For index.html, often you'll want to bootstrap with initial data
237237+ app.get("/", async c => {
238238+ let html = await readFile("/frontend/index.html", import.meta.url);
239239+240240+ // Inject data to avoid extra round-trips
241241+ const initialData = await fetchInitialData();
242242+ const dataScript = `<script>
243243+ window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};
244244+ </script>`;
245245+246246+ html = html.replace("</head>", `${dataScript}</head>`);
247247+ return c.html(html);
248248+ });
249249+ ```
250250+- Create RESTful API routes for CRUD operations
251251+- Always include this snippet at the top-level Hono app to re-throwing errors to see full stack traces:
252252+ ```ts
253253+ // Unwrap Hono errors to see original error details
254254+ app.onError((err, c) => {
255255+ throw err;
256256+ });
257257+ ```
258258+259259+### Database Patterns
260260+- Run migrations on startup or comment out for performance
261261+- Change table names when modifying schemas rather than altering
262262+- Export clear query functions with proper TypeScript typing
263263+264264+## Common Gotchas and Solutions
265265+266266+1. **Environment Limitations:**
267267+ - Val Town runs on Deno in a serverless context, not Node.js
268268+ - Code in `shared/` must work in both frontend and backend environments
269269+ - Cannot use `Deno` keyword in shared code
270270+ - Use `https://esm.sh` for imports that work in both environments
271271+272272+2. **SQLite Peculiarities:**
273273+ - Limited support for ALTER TABLE operations
274274+ - Create new tables with updated schemas and copy data when needed
275275+ - Always run table creation before querying
276276+277277+3. **React Configuration:**
278278+ - All React dependencies must be pinned to 18.2.0
279279+ - Always include `@jsxImportSource https://esm.sh/react@18.2.0` at the top of React files
280280+ - Rendering issues often come from mismatched React versions
281281+282282+4. **File Handling:**
283283+ - Val Town only supports text files, not binary
284284+ - Use the provided utilities to read files across branches and forks
285285+ - For files in the project, use `readFile` helpers
286286+287287+5. **API Design:**
288288+ - `fetch` handler is the entry point for HTTP vals
289289+ - Run the Hono app with `export default app.fetch // This is the entry point for HTTP vals`
290290+
+15
CHANGELOG.md
···11+# Changelog
22+33+## [0.1.0] - 2025-11-28
44+55+Initial release of Driftline Analytics.
66+77+### Added
88+99+- Event collection API (`POST /collect`)
1010+- Stats API endpoints (`GET /stats/:appView`)
1111+- Per-app-view API key authentication
1212+- TypeScript client library with `AnalyticsClient` class
1313+- `deriveUidFromDid` helper for anonymous user ID generation
1414+- Admin endpoint for API key management
1515+- SQLite storage with indexed queries
+118
README.md
···11+# Driftline Analytics
22+33+Anonymous analytics service for ATProto app views, hosted on Valtown.
44+55+## Features
66+77+- Anonymous by design: users are identified by pseudonymous IDs derived from DIDs
88+- Per-app-view isolation: same user gets different IDs across different app views
99+- Simple event model: accounts, views, and actions
1010+- JSON stats API
1111+1212+## API Endpoints
1313+1414+Base URL: `https://driftline.val.run`
1515+1616+### Health Check
1717+1818+```
1919+GET /
2020+```
2121+2222+### Collect Events
2323+2424+```
2525+POST /collect
2626+Headers: X-API-Key: <your-api-key>
2727+Content-Type: application/json
2828+2929+Body (single event):
3030+{
3131+ "v": 1,
3232+ "appView": "kipclip.com",
3333+ "env": "prod",
3434+ "ts": "2025-01-15T10:30:00.000Z",
3535+ "uid": "a1b2c3d4e5f6",
3636+ "type": "action",
3737+ "name": "checkin_created",
3838+ "screen": "CheckinScreen",
3939+ "props": { "placeType": "cafe" }
4040+}
4141+4242+Body (batch):
4343+{
4444+ "events": [...]
4545+}
4646+```
4747+4848+Event types:
4949+- `account` - Track account creation (once per user)
5050+- `view` - Track screen impressions
5151+- `action` - Track user actions
5252+5353+### Get Stats
5454+5555+All stats endpoints require the `X-API-Key` header.
5656+5757+```
5858+GET /stats/:appView?env=prod
5959+GET /stats/:appView/accounts?env=prod
6060+GET /stats/:appView/users?env=prod
6161+GET /stats/:appView/events?env=prod
6262+```
6363+6464+## Client Usage
6565+6666+```typescript
6767+import {
6868+ AnalyticsClient,
6969+ deriveUidFromDid
7070+} from "https://esm.town/v/tijs/driftline-analytics/client/analytics-client.ts";
7171+7272+// Derive anonymous user ID from DID (use your app-specific salt)
7373+const uid = await deriveUidFromDid(user.did, KIPCLIP_SALT);
7474+7575+const analytics = new AnalyticsClient({
7676+ appView: "kipclip.com",
7777+ env: "prod",
7878+ collectorUrl: "https://driftline.val.run",
7979+ apiKey: KIPCLIP_API_KEY,
8080+ uid,
8181+});
8282+8383+// Track events
8484+await analytics.trackAccountCreated();
8585+await analytics.trackView("HomeScreen");
8686+await analytics.trackAction("checkin_created", "CheckinScreen", { placeType: "cafe" });
8787+```
8888+8989+## Anonymity
9090+9191+User IDs are derived using SHA-256:
9292+9393+```typescript
9494+uid = sha256(salt + did).slice(0, 12)
9595+```
9696+9797+- Each app view uses its own salt
9898+- Same DID produces different UIDs across app views
9999+- Server never sees the original DID
100100+101101+## Admin
102102+103103+Create API keys (requires `ADMIN_SECRET` env var):
104104+105105+```
106106+POST /admin/api-keys
107107+Headers: X-Admin-Secret: <admin-secret>
108108+Body: { "appView": "your-app.com" }
109109+```
110110+111111+## Development
112112+113113+```bash
114114+deno task fmt # Format code
115115+deno task lint # Lint code
116116+deno task check # Type check
117117+deno task deploy # Format, lint, check, and push to Valtown
118118+```
+58
backend/database/migrations.ts
···11+/**
22+ * Database migrations for Driftline Analytics
33+ */
44+55+import { sqlite } from "https://esm.town/v/stevekrouse/sqlite?v=13";
66+77+const EVENTS_TABLE = "driftline_events";
88+const API_KEYS_TABLE = "driftline_api_keys";
99+1010+export async function runMigrations(): Promise<void> {
1111+ // Events table
1212+ await sqlite.execute(`
1313+ CREATE TABLE IF NOT EXISTS ${EVENTS_TABLE} (
1414+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1515+ ts TEXT NOT NULL,
1616+ app_view TEXT NOT NULL,
1717+ env TEXT NOT NULL,
1818+ type TEXT NOT NULL,
1919+ name TEXT NOT NULL,
2020+ uid TEXT NOT NULL,
2121+ screen TEXT,
2222+ props TEXT
2323+ )
2424+ `);
2525+2626+ // Indexes for events
2727+ await sqlite.execute(`
2828+ CREATE INDEX IF NOT EXISTS ${EVENTS_TABLE}_app_view_idx
2929+ ON ${EVENTS_TABLE} (app_view, env, ts)
3030+ `);
3131+3232+ await sqlite.execute(`
3333+ CREATE INDEX IF NOT EXISTS ${EVENTS_TABLE}_type_idx
3434+ ON ${EVENTS_TABLE} (app_view, type, name)
3535+ `);
3636+3737+ await sqlite.execute(`
3838+ CREATE INDEX IF NOT EXISTS ${EVENTS_TABLE}_uid_idx
3939+ ON ${EVENTS_TABLE} (app_view, uid)
4040+ `);
4141+4242+ // API keys table
4343+ await sqlite.execute(`
4444+ CREATE TABLE IF NOT EXISTS ${API_KEYS_TABLE} (
4545+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4646+ app_view TEXT NOT NULL UNIQUE,
4747+ api_key TEXT NOT NULL,
4848+ created TEXT NOT NULL
4949+ )
5050+ `);
5151+5252+ await sqlite.execute(`
5353+ CREATE INDEX IF NOT EXISTS ${API_KEYS_TABLE}_key_idx
5454+ ON ${API_KEYS_TABLE} (api_key)
5555+ `);
5656+}
5757+5858+export { API_KEYS_TABLE, EVENTS_TABLE };
+240
backend/database/queries.ts
···11+/**
22+ * Database query functions for Driftline Analytics
33+ */
44+55+import { sqlite } from "https://esm.town/v/stevekrouse/sqlite?v=13";
66+import { API_KEYS_TABLE, EVENTS_TABLE } from "./migrations.ts";
77+import type {
88+ AnalyticsEvent,
99+ Environment,
1010+ EventType,
1111+ StatsResponse,
1212+} from "../../shared/types.ts";
1313+1414+// API Key functions
1515+1616+export async function validateApiKey(apiKey: string): Promise<string | null> {
1717+ const result = await sqlite.execute({
1818+ sql: `SELECT app_view FROM ${API_KEYS_TABLE} WHERE api_key = ?`,
1919+ args: [apiKey],
2020+ });
2121+2222+ if (result.rows.length === 0) {
2323+ return null;
2424+ }
2525+2626+ return result.rows[0].app_view as string;
2727+}
2828+2929+export async function createApiKey(
3030+ appView: string,
3131+ apiKey: string,
3232+): Promise<void> {
3333+ await sqlite.execute({
3434+ sql:
3535+ `INSERT INTO ${API_KEYS_TABLE} (app_view, api_key, created) VALUES (?, ?, ?)`,
3636+ args: [appView, apiKey, new Date().toISOString()],
3737+ });
3838+}
3939+4040+// Event storage
4141+4242+export async function storeEvents(events: AnalyticsEvent[]): Promise<void> {
4343+ for (const event of events) {
4444+ await sqlite.execute({
4545+ sql: `
4646+ INSERT INTO ${EVENTS_TABLE} (ts, app_view, env, type, name, uid, screen, props)
4747+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
4848+ `,
4949+ args: [
5050+ event.ts,
5151+ event.appView,
5252+ event.env,
5353+ event.type,
5454+ event.name,
5555+ event.uid,
5656+ event.screen ?? null,
5757+ event.props ? JSON.stringify(event.props) : null,
5858+ ],
5959+ });
6060+ }
6161+}
6262+6363+// Stats queries
6464+6565+export async function getAccountCount(
6666+ appView: string,
6767+ env: Environment,
6868+): Promise<number> {
6969+ const result = await sqlite.execute({
7070+ sql: `
7171+ SELECT COUNT(DISTINCT uid) as count
7272+ FROM ${EVENTS_TABLE}
7373+ WHERE app_view = ? AND env = ? AND type = 'account' AND name = 'account_created'
7474+ `,
7575+ args: [appView, env],
7676+ });
7777+7878+ return (result.rows[0]?.count as number) ?? 0;
7979+}
8080+8181+export async function getUniqueUsers(
8282+ appView: string,
8383+ env: Environment,
8484+): Promise<number> {
8585+ const result = await sqlite.execute({
8686+ sql: `
8787+ SELECT COUNT(DISTINCT uid) as count
8888+ FROM ${EVENTS_TABLE}
8989+ WHERE app_view = ? AND env = ?
9090+ `,
9191+ args: [appView, env],
9292+ });
9393+9494+ return (result.rows[0]?.count as number) ?? 0;
9595+}
9696+9797+export async function getTotalEvents(
9898+ appView: string,
9999+ env: Environment,
100100+): Promise<number> {
101101+ const result = await sqlite.execute({
102102+ sql: `
103103+ SELECT COUNT(*) as count
104104+ FROM ${EVENTS_TABLE}
105105+ WHERE app_view = ? AND env = ?
106106+ `,
107107+ args: [appView, env],
108108+ });
109109+110110+ return (result.rows[0]?.count as number) ?? 0;
111111+}
112112+113113+export async function getEventsByType(
114114+ appView: string,
115115+ env: Environment,
116116+): Promise<Record<EventType, number>> {
117117+ const result = await sqlite.execute({
118118+ sql: `
119119+ SELECT type, COUNT(*) as count
120120+ FROM ${EVENTS_TABLE}
121121+ WHERE app_view = ? AND env = ?
122122+ GROUP BY type
123123+ `,
124124+ args: [appView, env],
125125+ });
126126+127127+ const counts: Record<EventType, number> = {
128128+ account: 0,
129129+ view: 0,
130130+ action: 0,
131131+ };
132132+133133+ for (const row of result.rows) {
134134+ const type = row.type as EventType;
135135+ counts[type] = row.count as number;
136136+ }
137137+138138+ return counts;
139139+}
140140+141141+export async function getEventsByName(
142142+ appView: string,
143143+ env: Environment,
144144+): Promise<Record<string, number>> {
145145+ const result = await sqlite.execute({
146146+ sql: `
147147+ SELECT name, COUNT(*) as count
148148+ FROM ${EVENTS_TABLE}
149149+ WHERE app_view = ? AND env = ?
150150+ GROUP BY name
151151+ ORDER BY count DESC
152152+ `,
153153+ args: [appView, env],
154154+ });
155155+156156+ const counts: Record<string, number> = {};
157157+ for (const row of result.rows) {
158158+ counts[row.name as string] = row.count as number;
159159+ }
160160+161161+ return counts;
162162+}
163163+164164+export async function getTopScreens(
165165+ appView: string,
166166+ env: Environment,
167167+ limit: number = 10,
168168+): Promise<Array<{ screen: string; count: number }>> {
169169+ const result = await sqlite.execute({
170170+ sql: `
171171+ SELECT screen, COUNT(*) as count
172172+ FROM ${EVENTS_TABLE}
173173+ WHERE app_view = ? AND env = ? AND screen IS NOT NULL
174174+ GROUP BY screen
175175+ ORDER BY count DESC
176176+ LIMIT ?
177177+ `,
178178+ args: [appView, env, limit],
179179+ });
180180+181181+ return result.rows.map((row) => ({
182182+ screen: row.screen as string,
183183+ count: row.count as number,
184184+ }));
185185+}
186186+187187+export async function getTopActions(
188188+ appView: string,
189189+ env: Environment,
190190+ limit: number = 10,
191191+): Promise<Array<{ name: string; count: number }>> {
192192+ const result = await sqlite.execute({
193193+ sql: `
194194+ SELECT name, COUNT(*) as count
195195+ FROM ${EVENTS_TABLE}
196196+ WHERE app_view = ? AND env = ? AND type = 'action'
197197+ GROUP BY name
198198+ ORDER BY count DESC
199199+ LIMIT ?
200200+ `,
201201+ args: [appView, env, limit],
202202+ });
203203+204204+ return result.rows.map((row) => ({
205205+ name: row.name as string,
206206+ count: row.count as number,
207207+ }));
208208+}
209209+210210+export async function getStats(
211211+ appView: string,
212212+ env: Environment,
213213+): Promise<StatsResponse> {
214214+ const [
215215+ totalAccounts,
216216+ totalUsers,
217217+ totalEvents,
218218+ eventsByType,
219219+ topScreens,
220220+ topActions,
221221+ ] = await Promise.all([
222222+ getAccountCount(appView, env),
223223+ getUniqueUsers(appView, env),
224224+ getTotalEvents(appView, env),
225225+ getEventsByType(appView, env),
226226+ getTopScreens(appView, env),
227227+ getTopActions(appView, env),
228228+ ]);
229229+230230+ return {
231231+ appView,
232232+ env,
233233+ totalAccounts,
234234+ totalUsers,
235235+ totalEvents,
236236+ eventsByType,
237237+ topScreens,
238238+ topActions,
239239+ };
240240+}
+56
backend/index.http.ts
···11+/**
22+ * Driftline Analytics - Main HTTP entry point
33+ *
44+ * Anonymous analytics service for ATProto app views.
55+ */
66+77+import { Hono } from "https://esm.sh/hono@4.4.0";
88+import { cors } from "https://esm.sh/hono@4.4.0/cors";
99+import { runMigrations } from "./database/migrations.ts";
1010+import { admin } from "./routes/admin.ts";
1111+import { collector } from "./routes/collector.ts";
1212+import { stats } from "./routes/stats.ts";
1313+1414+const app = new Hono();
1515+1616+// Re-throw errors to see full stack traces
1717+app.onError((err, _c) => {
1818+ throw err;
1919+});
2020+2121+// Enable CORS for client-side tracking
2222+app.use(
2323+ "*",
2424+ cors({
2525+ origin: "*",
2626+ allowMethods: ["GET", "POST", "OPTIONS"],
2727+ allowHeaders: ["Content-Type", "X-API-Key"],
2828+ }),
2929+);
3030+3131+// Run migrations on startup
3232+let migrationsRan = false;
3333+app.use("*", async (_c, next) => {
3434+ if (!migrationsRan) {
3535+ await runMigrations();
3636+ migrationsRan = true;
3737+ }
3838+ await next();
3939+});
4040+4141+// Health check
4242+app.get("/", (c) => {
4343+ return c.json({
4444+ service: "driftline-analytics",
4545+ status: "ok",
4646+ version: 1,
4747+ });
4848+});
4949+5050+// Mount routes
5151+app.route("/admin", admin);
5252+app.route("/collect", collector);
5353+app.route("/stats", stats);
5454+5555+// Export for Valtown HTTP trigger
5656+export default app.fetch;
···11+/**
22+ * Script to create an API key for an app view.
33+ * Run via: deno run --allow-import scripts/create-api-key.ts <app_view>
44+ *
55+ * This script must be run in the Valtown environment (or use their API).
66+ * For local testing, copy this logic into a val and run it there.
77+ */
88+99+import { sqlite } from "https://esm.town/v/stevekrouse/sqlite?v=13";
1010+1111+const API_KEYS_TABLE = "driftline_api_keys";
1212+1313+async function createApiKey(appView: string): Promise<string> {
1414+ const apiKey = crypto.randomUUID();
1515+1616+ await sqlite.execute({
1717+ sql:
1818+ `INSERT INTO ${API_KEYS_TABLE} (app_view, api_key, created) VALUES (?, ?, ?)`,
1919+ args: [appView, apiKey, new Date().toISOString()],
2020+ });
2121+2222+ return apiKey;
2323+}
2424+2525+async function main() {
2626+ const appView = Deno.args[0];
2727+2828+ if (!appView) {
2929+ console.error("Usage: deno run scripts/create-api-key.ts <app_view>");
3030+ console.error("Example: deno run scripts/create-api-key.ts kipclip.com");
3131+ Deno.exit(1);
3232+ }
3333+3434+ console.log(`Creating API key for app view: ${appView}`);
3535+ const apiKey = await createApiKey(appView);
3636+ console.log(`API key created: ${apiKey}`);
3737+ console.log(`\nStore this in your Valtown secrets!`);
3838+}
3939+4040+main();