loopwind 0.24.1 → 0.25.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.
@@ -0,0 +1,2772 @@
1
+ # Loopwind API Service - Implementation Plan
2
+
3
+ ## Overview
4
+
5
+ This document outlines the step-by-step implementation plan for the Loopwind API service using a 100% Cloudflare stack with organization-based user management.
6
+
7
+ ### Architecture
8
+
9
+ ```
10
+ ┌─────────────────────────────────────────────────────────────────────────────┐
11
+ │ Domains │
12
+ ├─────────────────────────────────────────────────────────────────────────────┤
13
+ │ │
14
+ │ loopwind.dev api.loopwind.dev app.loopwind.dev │
15
+ │ ───────────── ──────────────── ───────────────── │
16
+ │ Marketing site REST API Dashboard UI │
17
+ │ Documentation Authentication User settings │
18
+ │ Blog Template CRUD Template management │
19
+ │ Rendering API Render history │
20
+ │ │
21
+ │ └── website/ └── platform/ └── website/src/app/ │
22
+ │ (Astro) (Workers) (Astro) │
23
+ │ │
24
+ └─────────────────────────────────────────────────────────────────────────────┘
25
+
26
+
27
+ ┌─────────────────────────────────────────────────────────────────────────────┐
28
+ │ Shared Cloudflare Resources │
29
+ ├─────────────────────────────────────────────────────────────────────────────┤
30
+ │ │
31
+ │ D1 Database KV Namespaces R2 Buckets │
32
+ │ ─────────── ────────────── ────────── │
33
+ │ • users • SESSIONS • loopwind-assets │
34
+ │ • organizations • RATE_LIMIT • loopwind-renders │
35
+ │ • templates • CACHE │
36
+ │ • render_jobs │
37
+ │ │
38
+ └─────────────────────────────────────────────────────────────────────────────┘
39
+ ```
40
+
41
+ ### Tech Stack
42
+
43
+ | Component | Technology |
44
+ |-----------|------------|
45
+ | Marketing & Docs | Astro (website/) |
46
+ | Dashboard UI | Astro (website/src/pages/app/) |
47
+ | API | Cloudflare Workers (platform/) |
48
+ | Database | Cloudflare D1 (SQLite) |
49
+ | Auth | Custom (bcrypt + JWT) |
50
+ | Sessions | Workers KV |
51
+ | File Storage | Cloudflare R2 |
52
+ | Video Rendering | Cloudflare Containers |
53
+ | Image Rendering | Workers (WASM) |
54
+ | CDN | Cloudflare (automatic) |
55
+
56
+ ### User Model
57
+
58
+ ```
59
+ Organization
60
+ └── Users (members)
61
+ └── API Keys
62
+ └── Templates (owned by org, created by user)
63
+ ```
64
+
65
+ ---
66
+
67
+ ## Phase 1: Project Setup & Database Schema
68
+
69
+ ### 1.1 Initialize Cloudflare Project
70
+
71
+ ```bash
72
+ # Create platform directory
73
+ mkdir platform
74
+ cd platform
75
+
76
+ # Initialize Workers project
77
+ npm create cloudflare@latest . -- --template worker-typescript
78
+
79
+ # Install dependencies
80
+ npm install hono # Lightweight web framework
81
+ npm install @cloudflare/workers-types
82
+ npm install bcryptjs # Password hashing (works in Workers)
83
+ npm install jose # JWT handling (edge-compatible)
84
+ npm install zod # Validation
85
+ npm install nanoid # ID generation
86
+ ```
87
+
88
+ ### 1.2 Project Structure
89
+
90
+ ```
91
+ loopwind/
92
+ ├── src/ # CLI source (existing)
93
+ ├── dist/ # CLI build (existing)
94
+ ├── website/ # loopwind.dev website (existing)
95
+ ├── platform/ # Cloudflare Workers API
96
+ │ ├── src/
97
+ │ │ ├── index.ts # Main worker entry
98
+ │ │ ├── routes/
99
+ │ │ │ ├── auth.ts # Login, register, logout
100
+ │ │ │ ├── organizations.ts # Org management
101
+ │ │ │ ├── users.ts # User management
102
+ │ │ │ ├── templates.ts # Template CRUD & publish
103
+ │ │ │ ├── render.ts # Image/video rendering
104
+ │ │ │ └── keys.ts # API key management
105
+ │ │ ├── middleware/
106
+ │ │ │ ├── auth.ts # JWT validation
107
+ │ │ │ ├── rateLimit.ts # Rate limiting
108
+ │ │ │ └── orgAccess.ts # Organization access control
109
+ │ │ ├── services/
110
+ │ │ │ ├── auth.ts # Auth logic
111
+ │ │ │ ├── render.ts # Render orchestration
112
+ │ │ │ └── storage.ts # R2 operations
113
+ │ │ ├── db/
114
+ │ │ │ ├── schema.sql # D1 schema
115
+ │ │ │ └── queries.ts # Typed queries
116
+ │ │ ├── lib/
117
+ │ │ │ ├── jwt.ts # JWT helpers
118
+ │ │ │ ├── password.ts # Password hashing
119
+ │ │ │ └── id.ts # ID generation
120
+ │ │ └── types/
121
+ │ │ └── env.ts # Environment bindings
122
+ │ ├── container/
123
+ │ │ ├── Dockerfile # Video renderer container
124
+ │ │ ├── render-server.ts # Container render service
125
+ │ │ └── package.json
126
+ │ ├── migrations/
127
+ │ │ └── 0001_initial.sql # Database migrations
128
+ │ ├── wrangler.toml # Cloudflare config
129
+ │ ├── package.json
130
+ │ └── tsconfig.json
131
+ └── package.json # Root package (CLI)
132
+ ```
133
+
134
+ ### 1.3 Local Development
135
+
136
+ ```bash
137
+ # From project root
138
+ cd platform
139
+
140
+ # Install dependencies
141
+ npm install
142
+
143
+ # Run D1 migrations locally
144
+ npm run db:migrate:local
145
+ # → wrangler d1 execute loopwind --local --file=migrations/0001_initial.sql
146
+
147
+ # Start local dev server
148
+ npm run dev
149
+ # → wrangler dev --local
150
+
151
+ # Deploy to Cloudflare
152
+ npm run deploy
153
+ # → wrangler deploy
154
+ ```
155
+
156
+ ### 1.4 Wrangler Configuration
157
+
158
+ ```toml
159
+ # platform/wrangler.toml
160
+ name = "loopwind-api"
161
+ main = "src/index.ts"
162
+ compatibility_date = "2025-01-01"
163
+ node_compat = true
164
+
165
+ # Custom domain
166
+ routes = [
167
+ { pattern = "api.loopwind.dev", custom_domain = true }
168
+ ]
169
+
170
+ [limits]
171
+ cpu_ms = 300000 # 5 minutes for complex renders
172
+
173
+ # D1 Database
174
+ [[d1_databases]]
175
+ binding = "DB"
176
+ database_name = "loopwind"
177
+ database_id = "xxxxx" # Created via `wrangler d1 create loopwind`
178
+
179
+ # R2 Storage
180
+ [[r2_buckets]]
181
+ binding = "STORAGE"
182
+ bucket_name = "loopwind-assets"
183
+
184
+ [[r2_buckets]]
185
+ binding = "RENDERS"
186
+ bucket_name = "loopwind-renders"
187
+
188
+ # KV for sessions and rate limiting
189
+ [[kv_namespaces]]
190
+ binding = "SESSIONS"
191
+ id = "xxxxx"
192
+
193
+ [[kv_namespaces]]
194
+ binding = "RATE_LIMIT"
195
+ id = "xxxxx"
196
+
197
+ [[kv_namespaces]]
198
+ binding = "CACHE"
199
+ id = "xxxxx"
200
+
201
+ # Containers for video rendering
202
+ [[containers]]
203
+ binding = "VIDEO_RENDERER"
204
+ class_name = "VideoRenderer"
205
+ image = "./container"
206
+
207
+ # Durable Objects for job orchestration
208
+ [[durable_objects.bindings]]
209
+ name = "RENDER_JOBS"
210
+ class_name = "RenderJobOrchestrator"
211
+
212
+ [[migrations]]
213
+ tag = "v1"
214
+ new_classes = ["RenderJobOrchestrator"]
215
+
216
+ # Environment variables
217
+ [vars]
218
+ JWT_ISSUER = "loopwind.dev"
219
+ JWT_AUDIENCE = "loopwind-api"
220
+ CORS_ORIGINS = "https://loopwind.dev,https://app.loopwind.dev"
221
+
222
+ # Secrets (set via `wrangler secret put`)
223
+ # JWT_SECRET
224
+ ```
225
+
226
+ ### 1.5 CORS Configuration
227
+
228
+ Since the API runs on a separate subdomain, CORS must be configured:
229
+
230
+ ```typescript
231
+ // platform/src/index.ts
232
+ import { cors } from 'hono/cors';
233
+
234
+ app.use('*', cors({
235
+ origin: (origin) => {
236
+ const allowed = [
237
+ 'https://loopwind.dev',
238
+ 'https://app.loopwind.dev',
239
+ 'http://localhost:4321', // Astro dev
240
+ 'http://localhost:3000',
241
+ ];
242
+ return allowed.includes(origin) ? origin : null;
243
+ },
244
+ credentials: true,
245
+ allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
246
+ allowHeaders: ['Content-Type', 'Authorization'],
247
+ exposeHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-Render-Duration'],
248
+ maxAge: 86400,
249
+ }));
250
+ ```
251
+
252
+ ### 1.6 Custom Domains Setup
253
+
254
+ ```bash
255
+ # In Cloudflare Dashboard or via CLI:
256
+
257
+ # 1. Add api.loopwind.dev to your zone
258
+ # 2. Deploy platform worker
259
+ cd platform
260
+ wrangler deploy
261
+
262
+ # 3. The custom_domain in wrangler.toml will automatically:
263
+ # - Create DNS record
264
+ # - Provision SSL certificate
265
+ # - Route traffic to the worker
266
+
267
+ # For app.loopwind.dev (dashboard), configure in website deployment
268
+ # This can be a separate Cloudflare Pages project or route
269
+ ```
270
+
271
+ ### 1.7 Database Schema
272
+
273
+ ```sql
274
+ -- migrations/0001_initial.sql
275
+
276
+ -- Organizations
277
+ CREATE TABLE organizations (
278
+ id TEXT PRIMARY KEY, -- org_xxxxx
279
+ name TEXT NOT NULL,
280
+ slug TEXT UNIQUE NOT NULL, -- URL-safe name
281
+ plan TEXT DEFAULT 'free', -- free, pro, business
282
+
283
+ -- Limits based on plan
284
+ image_renders_limit INTEGER DEFAULT 100,
285
+ video_renders_limit INTEGER DEFAULT 10,
286
+ templates_limit INTEGER DEFAULT 5,
287
+ members_limit INTEGER DEFAULT 1,
288
+
289
+ -- Usage counters (reset monthly)
290
+ image_renders_used INTEGER DEFAULT 0,
291
+ video_renders_used INTEGER DEFAULT 0,
292
+ usage_reset_at TEXT, -- ISO date of next reset
293
+
294
+ created_at TEXT DEFAULT (datetime('now')),
295
+ updated_at TEXT DEFAULT (datetime('now'))
296
+ );
297
+
298
+ -- Users
299
+ CREATE TABLE users (
300
+ id TEXT PRIMARY KEY, -- usr_xxxxx
301
+ org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
302
+
303
+ email TEXT UNIQUE NOT NULL,
304
+ username TEXT UNIQUE NOT NULL,
305
+ password_hash TEXT NOT NULL,
306
+
307
+ name TEXT,
308
+ avatar_url TEXT,
309
+ role TEXT DEFAULT 'member', -- owner, admin, member
310
+
311
+ email_verified_at TEXT,
312
+ last_login_at TEXT,
313
+
314
+ created_at TEXT DEFAULT (datetime('now')),
315
+ updated_at TEXT DEFAULT (datetime('now'))
316
+ );
317
+
318
+ CREATE INDEX idx_users_org ON users(org_id);
319
+ CREATE INDEX idx_users_email ON users(email);
320
+ CREATE INDEX idx_users_username ON users(username);
321
+
322
+ -- API Keys
323
+ CREATE TABLE api_keys (
324
+ id TEXT PRIMARY KEY, -- key_xxxxx
325
+ org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
326
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
327
+
328
+ name TEXT NOT NULL,
329
+ key_hash TEXT NOT NULL, -- bcrypt hash
330
+ key_prefix TEXT NOT NULL, -- lw_live_xxxx (for display)
331
+
332
+ scopes TEXT DEFAULT '[]', -- JSON array: ["render:read", "render:write"]
333
+
334
+ last_used_at TEXT,
335
+ expires_at TEXT, -- Optional expiration
336
+
337
+ created_at TEXT DEFAULT (datetime('now'))
338
+ );
339
+
340
+ CREATE INDEX idx_api_keys_org ON api_keys(org_id);
341
+ CREATE INDEX idx_api_keys_prefix ON api_keys(key_prefix);
342
+
343
+ -- Templates
344
+ CREATE TABLE templates (
345
+ id TEXT PRIMARY KEY, -- tpl_xxxxx
346
+ org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
347
+ created_by TEXT NOT NULL REFERENCES users(id),
348
+
349
+ name TEXT NOT NULL,
350
+ slug TEXT NOT NULL, -- org-slug/template-name
351
+ description TEXT,
352
+
353
+ type TEXT NOT NULL, -- image, video
354
+ meta TEXT NOT NULL, -- JSON: { size, props, video? }
355
+
356
+ is_private INTEGER DEFAULT 0,
357
+ tags TEXT DEFAULT '[]', -- JSON array
358
+
359
+ -- Stats
360
+ downloads INTEGER DEFAULT 0,
361
+ renders INTEGER DEFAULT 0,
362
+
363
+ created_at TEXT DEFAULT (datetime('now')),
364
+ updated_at TEXT DEFAULT (datetime('now')),
365
+
366
+ UNIQUE(org_id, name)
367
+ );
368
+
369
+ CREATE INDEX idx_templates_org ON templates(org_id);
370
+ CREATE INDEX idx_templates_slug ON templates(slug);
371
+ CREATE INDEX idx_templates_type ON templates(type);
372
+
373
+ -- Template Versions
374
+ CREATE TABLE template_versions (
375
+ id TEXT PRIMARY KEY, -- ver_xxxxx
376
+ template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE CASCADE,
377
+
378
+ version TEXT NOT NULL, -- Semver: 1.0.0
379
+ files TEXT NOT NULL, -- JSON: [{ path, r2_key, checksum }]
380
+
381
+ published_at TEXT DEFAULT (datetime('now')),
382
+
383
+ UNIQUE(template_id, version)
384
+ );
385
+
386
+ CREATE INDEX idx_versions_template ON template_versions(template_id);
387
+
388
+ -- Render Jobs (for async video rendering)
389
+ CREATE TABLE render_jobs (
390
+ id TEXT PRIMARY KEY, -- job_xxxxx
391
+ org_id TEXT NOT NULL REFERENCES organizations(id),
392
+ user_id TEXT REFERENCES users(id),
393
+ template_id TEXT NOT NULL REFERENCES templates(id),
394
+
395
+ status TEXT DEFAULT 'queued', -- queued, processing, completed, failed
396
+
397
+ props TEXT, -- JSON
398
+ format TEXT NOT NULL, -- mp4, gif
399
+
400
+ progress REAL DEFAULT 0, -- 0.0 - 1.0
401
+ current_frame INTEGER,
402
+ total_frames INTEGER,
403
+
404
+ output_key TEXT, -- R2 key for output
405
+ error TEXT,
406
+
407
+ webhook_url TEXT,
408
+ webhook_sent INTEGER DEFAULT 0,
409
+
410
+ started_at TEXT,
411
+ completed_at TEXT,
412
+ created_at TEXT DEFAULT (datetime('now'))
413
+ );
414
+
415
+ CREATE INDEX idx_jobs_org ON render_jobs(org_id);
416
+ CREATE INDEX idx_jobs_status ON render_jobs(status);
417
+
418
+ -- Invitations (for adding users to orgs)
419
+ CREATE TABLE invitations (
420
+ id TEXT PRIMARY KEY, -- inv_xxxxx
421
+ org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
422
+ invited_by TEXT NOT NULL REFERENCES users(id),
423
+
424
+ email TEXT NOT NULL,
425
+ role TEXT DEFAULT 'member',
426
+
427
+ token TEXT UNIQUE NOT NULL, -- Random token for invite link
428
+
429
+ expires_at TEXT NOT NULL,
430
+ accepted_at TEXT,
431
+
432
+ created_at TEXT DEFAULT (datetime('now'))
433
+ );
434
+
435
+ CREATE INDEX idx_invitations_token ON invitations(token);
436
+ CREATE INDEX idx_invitations_email ON invitations(email);
437
+
438
+ -- Audit Log
439
+ CREATE TABLE audit_log (
440
+ id TEXT PRIMARY KEY,
441
+ org_id TEXT NOT NULL,
442
+ user_id TEXT,
443
+
444
+ action TEXT NOT NULL, -- user.login, template.publish, etc.
445
+ resource_type TEXT, -- user, template, api_key
446
+ resource_id TEXT,
447
+
448
+ metadata TEXT, -- JSON
449
+ ip_address TEXT,
450
+ user_agent TEXT,
451
+
452
+ created_at TEXT DEFAULT (datetime('now'))
453
+ );
454
+
455
+ CREATE INDEX idx_audit_org ON audit_log(org_id);
456
+ CREATE INDEX idx_audit_created ON audit_log(created_at);
457
+ ```
458
+
459
+ ### 1.8 Create D1 Database
460
+
461
+ ```bash
462
+ # From platform directory
463
+ cd platform
464
+
465
+ # Create database
466
+ wrangler d1 create loopwind
467
+ # Copy the database_id to wrangler.toml
468
+
469
+ # Run migrations
470
+ wrangler d1 execute loopwind --file=./migrations/0001_initial.sql
471
+
472
+ # Create KV namespaces
473
+ wrangler kv:namespace create SESSIONS
474
+ wrangler kv:namespace create RATE_LIMIT
475
+ wrangler kv:namespace create CACHE
476
+ # Copy the IDs to wrangler.toml
477
+
478
+ # Create R2 buckets
479
+ wrangler r2 bucket create loopwind-assets
480
+ wrangler r2 bucket create loopwind-renders
481
+
482
+ # Set secrets
483
+ wrangler secret put JWT_SECRET
484
+ # Enter a secure random string (64+ chars)
485
+ ```
486
+
487
+ ---
488
+
489
+ ## Phase 2: Authentication System
490
+
491
+ ### 2.1 Environment Types
492
+
493
+ ```typescript
494
+ // src/types/env.ts
495
+ export interface Env {
496
+ // D1
497
+ DB: D1Database;
498
+
499
+ // R2
500
+ STORAGE: R2Bucket;
501
+ RENDERS: R2Bucket;
502
+
503
+ // KV
504
+ SESSIONS: KVNamespace;
505
+ RATE_LIMIT: KVNamespace;
506
+ CACHE: KVNamespace;
507
+
508
+ // Containers
509
+ VIDEO_RENDERER: Container;
510
+
511
+ // Durable Objects
512
+ RENDER_JOBS: DurableObjectNamespace;
513
+
514
+ // Secrets
515
+ JWT_SECRET: string;
516
+
517
+ // Vars
518
+ JWT_ISSUER: string;
519
+ JWT_AUDIENCE: string;
520
+ }
521
+
522
+ export interface User {
523
+ id: string;
524
+ org_id: string;
525
+ email: string;
526
+ username: string;
527
+ name: string | null;
528
+ role: 'owner' | 'admin' | 'member';
529
+ }
530
+
531
+ export interface Organization {
532
+ id: string;
533
+ name: string;
534
+ slug: string;
535
+ plan: 'free' | 'pro' | 'business';
536
+ }
537
+
538
+ export interface JWTPayload {
539
+ sub: string; // user_id
540
+ org: string; // org_id
541
+ role: string; // user role
542
+ iss: string;
543
+ aud: string;
544
+ iat: number;
545
+ exp: number;
546
+ }
547
+ ```
548
+
549
+ ### 2.2 Password Utilities
550
+
551
+ ```typescript
552
+ // src/lib/password.ts
553
+ import bcrypt from 'bcryptjs';
554
+
555
+ const SALT_ROUNDS = 12;
556
+
557
+ export async function hashPassword(password: string): Promise<string> {
558
+ return bcrypt.hash(password, SALT_ROUNDS);
559
+ }
560
+
561
+ export async function verifyPassword(password: string, hash: string): Promise<boolean> {
562
+ return bcrypt.compare(password, hash);
563
+ }
564
+
565
+ export function validatePasswordStrength(password: string): { valid: boolean; errors: string[] } {
566
+ const errors: string[] = [];
567
+
568
+ if (password.length < 8) {
569
+ errors.push('Password must be at least 8 characters');
570
+ }
571
+ if (!/[A-Z]/.test(password)) {
572
+ errors.push('Password must contain an uppercase letter');
573
+ }
574
+ if (!/[a-z]/.test(password)) {
575
+ errors.push('Password must contain a lowercase letter');
576
+ }
577
+ if (!/[0-9]/.test(password)) {
578
+ errors.push('Password must contain a number');
579
+ }
580
+
581
+ return { valid: errors.length === 0, errors };
582
+ }
583
+ ```
584
+
585
+ ### 2.3 JWT Utilities
586
+
587
+ ```typescript
588
+ // src/lib/jwt.ts
589
+ import { SignJWT, jwtVerify } from 'jose';
590
+ import type { JWTPayload, Env } from '../types/env';
591
+
592
+ const ACCESS_TOKEN_TTL = '15m';
593
+ const REFRESH_TOKEN_TTL = '7d';
594
+
595
+ export async function signAccessToken(
596
+ payload: Omit<JWTPayload, 'iss' | 'aud' | 'iat' | 'exp'>,
597
+ env: Env
598
+ ): Promise<string> {
599
+ const secret = new TextEncoder().encode(env.JWT_SECRET);
600
+
601
+ return new SignJWT(payload)
602
+ .setProtectedHeader({ alg: 'HS256' })
603
+ .setIssuer(env.JWT_ISSUER)
604
+ .setAudience(env.JWT_AUDIENCE)
605
+ .setIssuedAt()
606
+ .setExpirationTime(ACCESS_TOKEN_TTL)
607
+ .sign(secret);
608
+ }
609
+
610
+ export async function signRefreshToken(
611
+ userId: string,
612
+ env: Env
613
+ ): Promise<string> {
614
+ const secret = new TextEncoder().encode(env.JWT_SECRET);
615
+
616
+ return new SignJWT({ sub: userId, type: 'refresh' })
617
+ .setProtectedHeader({ alg: 'HS256' })
618
+ .setIssuer(env.JWT_ISSUER)
619
+ .setIssuedAt()
620
+ .setExpirationTime(REFRESH_TOKEN_TTL)
621
+ .sign(secret);
622
+ }
623
+
624
+ export async function verifyToken(token: string, env: Env): Promise<JWTPayload | null> {
625
+ try {
626
+ const secret = new TextEncoder().encode(env.JWT_SECRET);
627
+ const { payload } = await jwtVerify(token, secret, {
628
+ issuer: env.JWT_ISSUER,
629
+ audience: env.JWT_AUDIENCE,
630
+ });
631
+ return payload as JWTPayload;
632
+ } catch {
633
+ return null;
634
+ }
635
+ }
636
+ ```
637
+
638
+ ### 2.4 ID Generation
639
+
640
+ ```typescript
641
+ // src/lib/id.ts
642
+ import { nanoid } from 'nanoid';
643
+
644
+ export const generateId = {
645
+ org: () => `org_${nanoid(16)}`,
646
+ user: () => `usr_${nanoid(16)}`,
647
+ key: () => `key_${nanoid(16)}`,
648
+ template: () => `tpl_${nanoid(16)}`,
649
+ version: () => `ver_${nanoid(16)}`,
650
+ job: () => `job_${nanoid(16)}`,
651
+ invitation: () => `inv_${nanoid(16)}`,
652
+ };
653
+
654
+ export function generateApiKey(): { key: string; prefix: string } {
655
+ const key = `lw_live_${nanoid(32)}`;
656
+ const prefix = key.substring(0, 12);
657
+ return { key, prefix };
658
+ }
659
+
660
+ export function generateInviteToken(): string {
661
+ return nanoid(32);
662
+ }
663
+ ```
664
+
665
+ ### 2.5 Auth Routes
666
+
667
+ ```typescript
668
+ // src/routes/auth.ts
669
+ import { Hono } from 'hono';
670
+ import { z } from 'zod';
671
+ import type { Env, User } from '../types/env';
672
+ import { hashPassword, verifyPassword, validatePasswordStrength } from '../lib/password';
673
+ import { signAccessToken, signRefreshToken, verifyToken } from '../lib/jwt';
674
+ import { generateId } from '../lib/id';
675
+
676
+ const auth = new Hono<{ Bindings: Env }>();
677
+
678
+ // Validation schemas
679
+ const registerSchema = z.object({
680
+ email: z.string().email(),
681
+ username: z.string().min(3).max(30).regex(/^[a-z0-9_-]+$/i),
682
+ password: z.string().min(8),
683
+ name: z.string().optional(),
684
+ orgName: z.string().min(2).max(50),
685
+ });
686
+
687
+ const loginSchema = z.object({
688
+ email: z.string().email(),
689
+ password: z.string(),
690
+ });
691
+
692
+ // POST /auth/register
693
+ auth.post('/register', async (c) => {
694
+ const body = await c.req.json();
695
+ const parsed = registerSchema.safeParse(body);
696
+
697
+ if (!parsed.success) {
698
+ return c.json({ error: 'Validation failed', details: parsed.error.flatten() }, 400);
699
+ }
700
+
701
+ const { email, username, password, name, orgName } = parsed.data;
702
+
703
+ // Validate password strength
704
+ const pwCheck = validatePasswordStrength(password);
705
+ if (!pwCheck.valid) {
706
+ return c.json({ error: 'Weak password', details: pwCheck.errors }, 400);
707
+ }
708
+
709
+ // Check if email/username exists
710
+ const existing = await c.env.DB.prepare(
711
+ 'SELECT id FROM users WHERE email = ? OR username = ?'
712
+ ).bind(email, username).first();
713
+
714
+ if (existing) {
715
+ return c.json({ error: 'Email or username already exists' }, 409);
716
+ }
717
+
718
+ // Create organization
719
+ const orgId = generateId.org();
720
+ const orgSlug = orgName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
721
+
722
+ // Check org slug uniqueness
723
+ const existingOrg = await c.env.DB.prepare(
724
+ 'SELECT id FROM organizations WHERE slug = ?'
725
+ ).bind(orgSlug).first();
726
+
727
+ if (existingOrg) {
728
+ return c.json({ error: 'Organization name already taken' }, 409);
729
+ }
730
+
731
+ // Hash password
732
+ const passwordHash = await hashPassword(password);
733
+
734
+ // Create org and user in transaction
735
+ const userId = generateId.user();
736
+ const now = new Date().toISOString();
737
+
738
+ await c.env.DB.batch([
739
+ c.env.DB.prepare(`
740
+ INSERT INTO organizations (id, name, slug, usage_reset_at)
741
+ VALUES (?, ?, ?, ?)
742
+ `).bind(orgId, orgName, orgSlug, getNextMonthReset()),
743
+
744
+ c.env.DB.prepare(`
745
+ INSERT INTO users (id, org_id, email, username, password_hash, name, role)
746
+ VALUES (?, ?, ?, ?, ?, ?, 'owner')
747
+ `).bind(userId, orgId, email, username, passwordHash, name || null),
748
+ ]);
749
+
750
+ // Generate tokens
751
+ const accessToken = await signAccessToken({ sub: userId, org: orgId, role: 'owner' }, c.env);
752
+ const refreshToken = await signRefreshToken(userId, c.env);
753
+
754
+ // Store refresh token in KV
755
+ await c.env.SESSIONS.put(`refresh:${userId}`, refreshToken, { expirationTtl: 7 * 24 * 60 * 60 });
756
+
757
+ return c.json({
758
+ user: { id: userId, email, username, name, role: 'owner' },
759
+ organization: { id: orgId, name: orgName, slug: orgSlug },
760
+ accessToken,
761
+ refreshToken,
762
+ }, 201);
763
+ });
764
+
765
+ // POST /auth/login
766
+ auth.post('/login', async (c) => {
767
+ const body = await c.req.json();
768
+ const parsed = loginSchema.safeParse(body);
769
+
770
+ if (!parsed.success) {
771
+ return c.json({ error: 'Validation failed' }, 400);
772
+ }
773
+
774
+ const { email, password } = parsed.data;
775
+
776
+ // Find user
777
+ const user = await c.env.DB.prepare(`
778
+ SELECT u.id, u.org_id, u.email, u.username, u.name, u.role, u.password_hash,
779
+ o.name as org_name, o.slug as org_slug, o.plan
780
+ FROM users u
781
+ JOIN organizations o ON u.org_id = o.id
782
+ WHERE u.email = ?
783
+ `).bind(email).first<any>();
784
+
785
+ if (!user) {
786
+ return c.json({ error: 'Invalid credentials' }, 401);
787
+ }
788
+
789
+ // Verify password
790
+ const valid = await verifyPassword(password, user.password_hash);
791
+ if (!valid) {
792
+ return c.json({ error: 'Invalid credentials' }, 401);
793
+ }
794
+
795
+ // Update last login
796
+ await c.env.DB.prepare(
797
+ 'UPDATE users SET last_login_at = ? WHERE id = ?'
798
+ ).bind(new Date().toISOString(), user.id).run();
799
+
800
+ // Generate tokens
801
+ const accessToken = await signAccessToken(
802
+ { sub: user.id, org: user.org_id, role: user.role },
803
+ c.env
804
+ );
805
+ const refreshToken = await signRefreshToken(user.id, c.env);
806
+
807
+ // Store refresh token
808
+ await c.env.SESSIONS.put(`refresh:${user.id}`, refreshToken, { expirationTtl: 7 * 24 * 60 * 60 });
809
+
810
+ return c.json({
811
+ user: {
812
+ id: user.id,
813
+ email: user.email,
814
+ username: user.username,
815
+ name: user.name,
816
+ role: user.role,
817
+ },
818
+ organization: {
819
+ id: user.org_id,
820
+ name: user.org_name,
821
+ slug: user.org_slug,
822
+ plan: user.plan,
823
+ },
824
+ accessToken,
825
+ refreshToken,
826
+ });
827
+ });
828
+
829
+ // POST /auth/refresh
830
+ auth.post('/refresh', async (c) => {
831
+ const { refreshToken } = await c.req.json();
832
+
833
+ if (!refreshToken) {
834
+ return c.json({ error: 'Refresh token required' }, 400);
835
+ }
836
+
837
+ // Verify refresh token
838
+ const payload = await verifyToken(refreshToken, c.env);
839
+ if (!payload || payload.type !== 'refresh') {
840
+ return c.json({ error: 'Invalid refresh token' }, 401);
841
+ }
842
+
843
+ // Check if token is still valid in KV
844
+ const storedToken = await c.env.SESSIONS.get(`refresh:${payload.sub}`);
845
+ if (storedToken !== refreshToken) {
846
+ return c.json({ error: 'Refresh token revoked' }, 401);
847
+ }
848
+
849
+ // Get user
850
+ const user = await c.env.DB.prepare(`
851
+ SELECT id, org_id, role FROM users WHERE id = ?
852
+ `).bind(payload.sub).first<any>();
853
+
854
+ if (!user) {
855
+ return c.json({ error: 'User not found' }, 401);
856
+ }
857
+
858
+ // Generate new tokens
859
+ const newAccessToken = await signAccessToken(
860
+ { sub: user.id, org: user.org_id, role: user.role },
861
+ c.env
862
+ );
863
+ const newRefreshToken = await signRefreshToken(user.id, c.env);
864
+
865
+ // Update refresh token in KV
866
+ await c.env.SESSIONS.put(`refresh:${user.id}`, newRefreshToken, { expirationTtl: 7 * 24 * 60 * 60 });
867
+
868
+ return c.json({
869
+ accessToken: newAccessToken,
870
+ refreshToken: newRefreshToken,
871
+ });
872
+ });
873
+
874
+ // POST /auth/logout
875
+ auth.post('/logout', async (c) => {
876
+ const authHeader = c.req.header('Authorization');
877
+ if (!authHeader?.startsWith('Bearer ')) {
878
+ return c.json({ error: 'Unauthorized' }, 401);
879
+ }
880
+
881
+ const token = authHeader.slice(7);
882
+ const payload = await verifyToken(token, c.env);
883
+
884
+ if (payload) {
885
+ // Delete refresh token
886
+ await c.env.SESSIONS.delete(`refresh:${payload.sub}`);
887
+ }
888
+
889
+ return c.json({ success: true });
890
+ });
891
+
892
+ // Helper function
893
+ function getNextMonthReset(): string {
894
+ const now = new Date();
895
+ const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
896
+ return next.toISOString();
897
+ }
898
+
899
+ export default auth;
900
+ ```
901
+
902
+ ### 2.6 Auth Middleware
903
+
904
+ ```typescript
905
+ // src/middleware/auth.ts
906
+ import { Context, Next } from 'hono';
907
+ import { verifyToken } from '../lib/jwt';
908
+ import type { Env, User, JWTPayload } from '../types/env';
909
+
910
+ // Extend Hono context
911
+ declare module 'hono' {
912
+ interface ContextVariableMap {
913
+ user: JWTPayload;
914
+ apiKey?: { id: string; scopes: string[] };
915
+ }
916
+ }
917
+
918
+ export async function authMiddleware(c: Context<{ Bindings: Env }>, next: Next) {
919
+ const authHeader = c.req.header('Authorization');
920
+
921
+ if (!authHeader) {
922
+ return c.json({ error: 'Authorization required' }, 401);
923
+ }
924
+
925
+ // Check for Bearer token (JWT)
926
+ if (authHeader.startsWith('Bearer ')) {
927
+ const token = authHeader.slice(7);
928
+ const payload = await verifyToken(token, c.env);
929
+
930
+ if (!payload) {
931
+ return c.json({ error: 'Invalid or expired token' }, 401);
932
+ }
933
+
934
+ c.set('user', payload);
935
+ return next();
936
+ }
937
+
938
+ // Check for API key
939
+ if (authHeader.startsWith('ApiKey ')) {
940
+ const apiKey = authHeader.slice(7);
941
+ const keyPrefix = apiKey.substring(0, 12);
942
+
943
+ // Find API key by prefix
944
+ const key = await c.env.DB.prepare(`
945
+ SELECT ak.id, ak.key_hash, ak.scopes, ak.org_id, u.id as user_id, u.role
946
+ FROM api_keys ak
947
+ JOIN users u ON ak.user_id = u.id
948
+ WHERE ak.key_prefix = ?
949
+ AND (ak.expires_at IS NULL OR ak.expires_at > datetime('now'))
950
+ `).bind(keyPrefix).first<any>();
951
+
952
+ if (!key) {
953
+ return c.json({ error: 'Invalid API key' }, 401);
954
+ }
955
+
956
+ // Verify full key hash
957
+ const bcrypt = await import('bcryptjs');
958
+ const valid = await bcrypt.compare(apiKey, key.key_hash);
959
+
960
+ if (!valid) {
961
+ return c.json({ error: 'Invalid API key' }, 401);
962
+ }
963
+
964
+ // Update last used
965
+ await c.env.DB.prepare(
966
+ 'UPDATE api_keys SET last_used_at = ? WHERE id = ?'
967
+ ).bind(new Date().toISOString(), key.id).run();
968
+
969
+ // Set context
970
+ c.set('user', {
971
+ sub: key.user_id,
972
+ org: key.org_id,
973
+ role: key.role,
974
+ } as JWTPayload);
975
+ c.set('apiKey', { id: key.id, scopes: JSON.parse(key.scopes) });
976
+
977
+ return next();
978
+ }
979
+
980
+ return c.json({ error: 'Invalid authorization format' }, 401);
981
+ }
982
+
983
+ // Scope checking middleware
984
+ export function requireScopes(...scopes: string[]) {
985
+ return async (c: Context<{ Bindings: Env }>, next: Next) => {
986
+ const apiKey = c.get('apiKey');
987
+
988
+ // JWT auth has all scopes
989
+ if (!apiKey) {
990
+ return next();
991
+ }
992
+
993
+ // Check API key scopes
994
+ const hasScope = scopes.every(s => apiKey.scopes.includes(s));
995
+ if (!hasScope) {
996
+ return c.json({ error: 'Insufficient permissions', required: scopes }, 403);
997
+ }
998
+
999
+ return next();
1000
+ };
1001
+ }
1002
+
1003
+ // Role checking middleware
1004
+ export function requireRole(...roles: string[]) {
1005
+ return async (c: Context<{ Bindings: Env }>, next: Next) => {
1006
+ const user = c.get('user');
1007
+
1008
+ if (!roles.includes(user.role)) {
1009
+ return c.json({ error: 'Insufficient role', required: roles }, 403);
1010
+ }
1011
+
1012
+ return next();
1013
+ };
1014
+ }
1015
+ ```
1016
+
1017
+ ### 2.7 Rate Limiting Middleware
1018
+
1019
+ ```typescript
1020
+ // src/middleware/rateLimit.ts
1021
+ import { Context, Next } from 'hono';
1022
+ import type { Env } from '../types/env';
1023
+
1024
+ interface RateLimitConfig {
1025
+ windowMs: number; // Time window in ms
1026
+ maxRequests: number; // Max requests per window
1027
+ }
1028
+
1029
+ const PLAN_LIMITS: Record<string, RateLimitConfig> = {
1030
+ free: { windowMs: 60000, maxRequests: 10 },
1031
+ pro: { windowMs: 60000, maxRequests: 100 },
1032
+ business: { windowMs: 60000, maxRequests: 500 },
1033
+ };
1034
+
1035
+ export async function rateLimitMiddleware(c: Context<{ Bindings: Env }>, next: Next) {
1036
+ const user = c.get('user');
1037
+ if (!user) return next();
1038
+
1039
+ // Get org plan
1040
+ const org = await c.env.DB.prepare(
1041
+ 'SELECT plan FROM organizations WHERE id = ?'
1042
+ ).bind(user.org).first<{ plan: string }>();
1043
+
1044
+ const limits = PLAN_LIMITS[org?.plan || 'free'];
1045
+ const key = `rate:${user.org}:${Math.floor(Date.now() / limits.windowMs)}`;
1046
+
1047
+ // Get current count
1048
+ const current = await c.env.RATE_LIMIT.get(key);
1049
+ const count = current ? parseInt(current, 10) : 0;
1050
+
1051
+ if (count >= limits.maxRequests) {
1052
+ return c.json({
1053
+ error: 'Rate limit exceeded',
1054
+ retryAfter: Math.ceil(limits.windowMs / 1000),
1055
+ }, 429);
1056
+ }
1057
+
1058
+ // Increment counter
1059
+ await c.env.RATE_LIMIT.put(key, String(count + 1), {
1060
+ expirationTtl: Math.ceil(limits.windowMs / 1000),
1061
+ });
1062
+
1063
+ // Set rate limit headers
1064
+ c.header('X-RateLimit-Limit', String(limits.maxRequests));
1065
+ c.header('X-RateLimit-Remaining', String(limits.maxRequests - count - 1));
1066
+
1067
+ return next();
1068
+ }
1069
+ ```
1070
+
1071
+ ---
1072
+
1073
+ ## Phase 3: Template Publishing
1074
+
1075
+ ### 3.1 Template Routes
1076
+
1077
+ ```typescript
1078
+ // src/routes/templates.ts
1079
+ import { Hono } from 'hono';
1080
+ import { z } from 'zod';
1081
+ import type { Env } from '../types/env';
1082
+ import { authMiddleware, requireScopes } from '../middleware/auth';
1083
+ import { generateId } from '../lib/id';
1084
+
1085
+ const templates = new Hono<{ Bindings: Env }>();
1086
+
1087
+ // Apply auth to all routes
1088
+ templates.use('*', authMiddleware);
1089
+
1090
+ const publishSchema = z.object({
1091
+ name: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/),
1092
+ version: z.string().regex(/^\d+\.\d+\.\d+$/),
1093
+ description: z.string().optional(),
1094
+ isPrivate: z.boolean().default(false),
1095
+ tags: z.array(z.string()).default([]),
1096
+ meta: z.object({
1097
+ type: z.enum(['image', 'video']),
1098
+ size: z.object({ width: z.number(), height: z.number() }),
1099
+ video: z.object({ fps: z.number(), duration: z.number() }).optional(),
1100
+ props: z.record(z.string()),
1101
+ }),
1102
+ files: z.array(z.object({
1103
+ path: z.string(),
1104
+ content: z.string(), // Base64 encoded
1105
+ })),
1106
+ });
1107
+
1108
+ // POST /templates/publish
1109
+ templates.post('/publish', requireScopes('templates:write'), async (c) => {
1110
+ const user = c.get('user');
1111
+ const body = await c.req.json();
1112
+
1113
+ const parsed = publishSchema.safeParse(body);
1114
+ if (!parsed.success) {
1115
+ return c.json({ error: 'Validation failed', details: parsed.error.flatten() }, 400);
1116
+ }
1117
+
1118
+ const { name, version, description, isPrivate, tags, meta, files } = parsed.data;
1119
+
1120
+ // Get org for slug
1121
+ const org = await c.env.DB.prepare(
1122
+ 'SELECT slug, templates_limit FROM organizations WHERE id = ?'
1123
+ ).bind(user.org).first<any>();
1124
+
1125
+ // Check template limit
1126
+ const templateCount = await c.env.DB.prepare(
1127
+ 'SELECT COUNT(*) as count FROM templates WHERE org_id = ?'
1128
+ ).bind(user.org).first<{ count: number }>();
1129
+
1130
+ if (templateCount!.count >= org.templates_limit) {
1131
+ return c.json({ error: 'Template limit reached', limit: org.templates_limit }, 403);
1132
+ }
1133
+
1134
+ const slug = `${org.slug}/${name}`;
1135
+
1136
+ // Check if template exists
1137
+ let template = await c.env.DB.prepare(
1138
+ 'SELECT id FROM templates WHERE org_id = ? AND name = ?'
1139
+ ).bind(user.org, name).first<{ id: string }>();
1140
+
1141
+ const templateId = template?.id || generateId.template();
1142
+
1143
+ // Check version doesn't exist
1144
+ if (template) {
1145
+ const existingVersion = await c.env.DB.prepare(
1146
+ 'SELECT id FROM template_versions WHERE template_id = ? AND version = ?'
1147
+ ).bind(templateId, version).first();
1148
+
1149
+ if (existingVersion) {
1150
+ return c.json({ error: 'Version already exists' }, 409);
1151
+ }
1152
+ }
1153
+
1154
+ // Upload files to R2
1155
+ const uploadedFiles: { path: string; r2Key: string; checksum: string }[] = [];
1156
+
1157
+ for (const file of files) {
1158
+ const content = Buffer.from(file.content, 'base64');
1159
+ const checksum = await crypto.subtle.digest('SHA-256', content);
1160
+ const checksumHex = Array.from(new Uint8Array(checksum))
1161
+ .map(b => b.toString(16).padStart(2, '0')).join('');
1162
+
1163
+ const r2Key = `templates/${templateId}/${version}/${file.path}`;
1164
+
1165
+ await c.env.STORAGE.put(r2Key, content, {
1166
+ customMetadata: { checksum: checksumHex },
1167
+ });
1168
+
1169
+ uploadedFiles.push({ path: file.path, r2Key, checksum: checksumHex });
1170
+ }
1171
+
1172
+ // Create or update template
1173
+ const versionId = generateId.version();
1174
+
1175
+ if (!template) {
1176
+ await c.env.DB.batch([
1177
+ c.env.DB.prepare(`
1178
+ INSERT INTO templates (id, org_id, created_by, name, slug, description, type, meta, is_private, tags)
1179
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1180
+ `).bind(
1181
+ templateId, user.org, user.sub, name, slug,
1182
+ description || null, meta.type, JSON.stringify(meta),
1183
+ isPrivate ? 1 : 0, JSON.stringify(tags)
1184
+ ),
1185
+ c.env.DB.prepare(`
1186
+ INSERT INTO template_versions (id, template_id, version, files)
1187
+ VALUES (?, ?, ?, ?)
1188
+ `).bind(versionId, templateId, version, JSON.stringify(uploadedFiles)),
1189
+ ]);
1190
+ } else {
1191
+ await c.env.DB.batch([
1192
+ c.env.DB.prepare(`
1193
+ UPDATE templates SET description = ?, meta = ?, is_private = ?, tags = ?, updated_at = datetime('now')
1194
+ WHERE id = ?
1195
+ `).bind(description || null, JSON.stringify(meta), isPrivate ? 1 : 0, JSON.stringify(tags), templateId),
1196
+ c.env.DB.prepare(`
1197
+ INSERT INTO template_versions (id, template_id, version, files)
1198
+ VALUES (?, ?, ?, ?)
1199
+ `).bind(versionId, templateId, version, JSON.stringify(uploadedFiles)),
1200
+ ]);
1201
+ }
1202
+
1203
+ return c.json({
1204
+ id: templateId,
1205
+ name,
1206
+ slug,
1207
+ version,
1208
+ url: `https://loopwind.dev/${slug}`,
1209
+ publishedAt: new Date().toISOString(),
1210
+ }, 201);
1211
+ });
1212
+
1213
+ // GET /templates - List public templates or org templates
1214
+ templates.get('/', async (c) => {
1215
+ const user = c.get('user');
1216
+ const { q, type, tags, author, sort = 'recent', page = '1', limit = '20' } = c.req.query();
1217
+
1218
+ const offset = (parseInt(page) - 1) * parseInt(limit);
1219
+
1220
+ let query = `
1221
+ SELECT t.*, o.name as org_name, o.slug as org_slug,
1222
+ (SELECT version FROM template_versions WHERE template_id = t.id ORDER BY published_at DESC LIMIT 1) as latest_version
1223
+ FROM templates t
1224
+ JOIN organizations o ON t.org_id = o.id
1225
+ WHERE (t.is_private = 0 OR t.org_id = ?)
1226
+ `;
1227
+ const params: any[] = [user.org];
1228
+
1229
+ if (q) {
1230
+ query += ` AND (t.name LIKE ? OR t.description LIKE ?)`;
1231
+ params.push(`%${q}%`, `%${q}%`);
1232
+ }
1233
+
1234
+ if (type) {
1235
+ query += ` AND t.type = ?`;
1236
+ params.push(type);
1237
+ }
1238
+
1239
+ if (author) {
1240
+ query += ` AND o.slug = ?`;
1241
+ params.push(author);
1242
+ }
1243
+
1244
+ // Sort
1245
+ const sortMap: Record<string, string> = {
1246
+ recent: 'ORDER BY t.created_at DESC',
1247
+ popular: 'ORDER BY t.renders DESC',
1248
+ downloads: 'ORDER BY t.downloads DESC',
1249
+ };
1250
+ query += ` ${sortMap[sort] || sortMap.recent}`;
1251
+
1252
+ query += ` LIMIT ? OFFSET ?`;
1253
+ params.push(parseInt(limit), offset);
1254
+
1255
+ const results = await c.env.DB.prepare(query).bind(...params).all();
1256
+
1257
+ // Get total count
1258
+ let countQuery = `
1259
+ SELECT COUNT(*) as total FROM templates t
1260
+ JOIN organizations o ON t.org_id = o.id
1261
+ WHERE (t.is_private = 0 OR t.org_id = ?)
1262
+ `;
1263
+ const countParams: any[] = [user.org];
1264
+
1265
+ if (q) {
1266
+ countQuery += ` AND (t.name LIKE ? OR t.description LIKE ?)`;
1267
+ countParams.push(`%${q}%`, `%${q}%`);
1268
+ }
1269
+ if (type) {
1270
+ countQuery += ` AND t.type = ?`;
1271
+ countParams.push(type);
1272
+ }
1273
+
1274
+ const countResult = await c.env.DB.prepare(countQuery).bind(...countParams).first<{ total: number }>();
1275
+
1276
+ return c.json({
1277
+ templates: results.results,
1278
+ total: countResult?.total || 0,
1279
+ page: parseInt(page),
1280
+ pages: Math.ceil((countResult?.total || 0) / parseInt(limit)),
1281
+ });
1282
+ });
1283
+
1284
+ // GET /templates/:slug - Get template by slug
1285
+ templates.get('/:orgSlug/:name', async (c) => {
1286
+ const user = c.get('user');
1287
+ const slug = `${c.req.param('orgSlug')}/${c.req.param('name')}`;
1288
+
1289
+ const template = await c.env.DB.prepare(`
1290
+ SELECT t.*, o.name as org_name, o.slug as org_slug
1291
+ FROM templates t
1292
+ JOIN organizations o ON t.org_id = o.id
1293
+ WHERE t.slug = ? AND (t.is_private = 0 OR t.org_id = ?)
1294
+ `).bind(slug, user.org).first();
1295
+
1296
+ if (!template) {
1297
+ return c.json({ error: 'Template not found' }, 404);
1298
+ }
1299
+
1300
+ // Get versions
1301
+ const versions = await c.env.DB.prepare(`
1302
+ SELECT version, published_at FROM template_versions
1303
+ WHERE template_id = ? ORDER BY published_at DESC
1304
+ `).bind(template.id).all();
1305
+
1306
+ return c.json({
1307
+ ...template,
1308
+ meta: JSON.parse(template.meta as string),
1309
+ tags: JSON.parse(template.tags as string),
1310
+ versions: versions.results,
1311
+ });
1312
+ });
1313
+
1314
+ export default templates;
1315
+ ```
1316
+
1317
+ ### 3.2 CLI Publish Command
1318
+
1319
+ ```typescript
1320
+ // CLI: src/commands/publish.ts (in loopwind CLI)
1321
+ import { Command } from 'commander';
1322
+ import fs from 'fs';
1323
+ import path from 'path';
1324
+ import { getCredentials, getConfig } from '../lib/config';
1325
+ import { loadTemplate } from '../lib/renderer';
1326
+
1327
+ export const publishCommand = new Command('publish')
1328
+ .description('Publish template to loopwind.dev')
1329
+ .argument('[template]', 'Template name to publish')
1330
+ .option('--version <version>', 'Semantic version')
1331
+ .option('--private', 'Make template private')
1332
+ .option('--description <text>', 'Template description')
1333
+ .option('--dry-run', 'Validate without publishing')
1334
+ .action(async (templateName, options) => {
1335
+ // Get credentials
1336
+ const credentials = await getCredentials();
1337
+ if (!credentials) {
1338
+ console.error('Not logged in. Run: loopwind login');
1339
+ process.exit(1);
1340
+ }
1341
+
1342
+ // Load template
1343
+ const config = await getConfig();
1344
+ const templatePath = path.join(config.paths.root, templateName, 'template.tsx');
1345
+
1346
+ if (!fs.existsSync(templatePath)) {
1347
+ console.error(`Template not found: ${templateName}`);
1348
+ process.exit(1);
1349
+ }
1350
+
1351
+ const { meta } = await loadTemplate(templatePath);
1352
+
1353
+ // Collect all files in template directory
1354
+ const templateDir = path.dirname(templatePath);
1355
+ const files: { path: string; content: string }[] = [];
1356
+
1357
+ function collectFiles(dir: string, base = '') {
1358
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1359
+ const fullPath = path.join(dir, entry.name);
1360
+ const relativePath = path.join(base, entry.name);
1361
+
1362
+ if (entry.isDirectory()) {
1363
+ collectFiles(fullPath, relativePath);
1364
+ } else {
1365
+ files.push({
1366
+ path: relativePath,
1367
+ content: fs.readFileSync(fullPath).toString('base64'),
1368
+ });
1369
+ }
1370
+ }
1371
+ }
1372
+
1373
+ collectFiles(templateDir);
1374
+
1375
+ // Determine version
1376
+ const version = options.version || await promptVersion(templateName, credentials);
1377
+
1378
+ if (options.dryRun) {
1379
+ console.log('Dry run - would publish:');
1380
+ console.log(` Template: ${templateName}`);
1381
+ console.log(` Version: ${version}`);
1382
+ console.log(` Files: ${files.length}`);
1383
+ console.log(` Type: ${meta.type}`);
1384
+ return;
1385
+ }
1386
+
1387
+ // Publish
1388
+ console.log(`Publishing ${templateName}@${version}...`);
1389
+
1390
+ const response = await fetch('https://api.loopwind.dev/templates/publish', {
1391
+ method: 'POST',
1392
+ headers: {
1393
+ 'Authorization': `Bearer ${credentials.token}`,
1394
+ 'Content-Type': 'application/json',
1395
+ },
1396
+ body: JSON.stringify({
1397
+ name: templateName,
1398
+ version,
1399
+ description: options.description || meta.description,
1400
+ isPrivate: options.private || false,
1401
+ meta: {
1402
+ type: meta.type || 'image',
1403
+ size: meta.size,
1404
+ video: meta.video,
1405
+ props: meta.props,
1406
+ },
1407
+ files,
1408
+ }),
1409
+ });
1410
+
1411
+ if (!response.ok) {
1412
+ const error = await response.json();
1413
+ console.error(`Failed to publish: ${error.error}`);
1414
+ process.exit(1);
1415
+ }
1416
+
1417
+ const result = await response.json();
1418
+ console.log(`✓ Published ${result.slug}@${result.version}`);
1419
+ console.log(` URL: ${result.url}`);
1420
+ });
1421
+ ```
1422
+
1423
+ ---
1424
+
1425
+ ## Phase 4: Image Rendering API
1426
+
1427
+ ### 4.1 Render Routes
1428
+
1429
+ ```typescript
1430
+ // src/routes/render.ts
1431
+ import { Hono } from 'hono';
1432
+ import { z } from 'zod';
1433
+ import type { Env } from '../types/env';
1434
+ import { authMiddleware, requireScopes } from '../middleware/auth';
1435
+ import { generateId } from '../lib/id';
1436
+
1437
+ const render = new Hono<{ Bindings: Env }>();
1438
+
1439
+ render.use('*', authMiddleware);
1440
+
1441
+ const imageRenderSchema = z.object({
1442
+ template: z.string(), // slug or id
1443
+ version: z.string().optional(), // defaults to latest
1444
+ props: z.record(z.any()),
1445
+ format: z.enum(['png', 'jpg', 'webp', 'svg']).default('png'),
1446
+ scale: z.number().min(1).max(3).default(1),
1447
+ });
1448
+
1449
+ const videoRenderSchema = z.object({
1450
+ template: z.string(),
1451
+ version: z.string().optional(),
1452
+ props: z.record(z.any()),
1453
+ format: z.enum(['mp4', 'gif']).default('mp4'),
1454
+ webhook: z.string().url().optional(),
1455
+ });
1456
+
1457
+ // POST /render - Synchronous image rendering
1458
+ render.post('/', requireScopes('render:write'), async (c) => {
1459
+ const user = c.get('user');
1460
+ const body = await c.req.json();
1461
+
1462
+ const parsed = imageRenderSchema.safeParse(body);
1463
+ if (!parsed.success) {
1464
+ return c.json({ error: 'Validation failed', details: parsed.error.flatten() }, 400);
1465
+ }
1466
+
1467
+ const { template, version, props, format, scale } = parsed.data;
1468
+
1469
+ // Check usage limits
1470
+ const org = await c.env.DB.prepare(`
1471
+ SELECT image_renders_limit, image_renders_used, usage_reset_at
1472
+ FROM organizations WHERE id = ?
1473
+ `).bind(user.org).first<any>();
1474
+
1475
+ // Reset usage if needed
1476
+ if (new Date(org.usage_reset_at) < new Date()) {
1477
+ await c.env.DB.prepare(`
1478
+ UPDATE organizations
1479
+ SET image_renders_used = 0, video_renders_used = 0, usage_reset_at = ?
1480
+ WHERE id = ?
1481
+ `).bind(getNextMonthReset(), user.org).run();
1482
+ org.image_renders_used = 0;
1483
+ }
1484
+
1485
+ if (org.image_renders_used >= org.image_renders_limit) {
1486
+ return c.json({ error: 'Image render limit exceeded', limit: org.image_renders_limit }, 403);
1487
+ }
1488
+
1489
+ // Get template
1490
+ const tpl = await getTemplate(c.env, template, version, user.org);
1491
+ if (!tpl) {
1492
+ return c.json({ error: 'Template not found' }, 404);
1493
+ }
1494
+
1495
+ if (tpl.type !== 'image') {
1496
+ return c.json({ error: 'Use /render/async for video templates' }, 400);
1497
+ }
1498
+
1499
+ // Load template files from R2
1500
+ const templateCode = await loadTemplateFromR2(c.env, tpl);
1501
+
1502
+ // Render image (using loopwind core renderer)
1503
+ const startTime = Date.now();
1504
+ const imageBuffer = await renderImage(templateCode, props, format, scale);
1505
+ const duration = Date.now() - startTime;
1506
+
1507
+ // Increment usage
1508
+ await c.env.DB.prepare(
1509
+ 'UPDATE organizations SET image_renders_used = image_renders_used + 1 WHERE id = ?'
1510
+ ).bind(user.org).run();
1511
+
1512
+ // Increment template renders
1513
+ await c.env.DB.prepare(
1514
+ 'UPDATE templates SET renders = renders + 1 WHERE id = ?'
1515
+ ).bind(tpl.id).run();
1516
+
1517
+ // Return image
1518
+ const contentType = {
1519
+ png: 'image/png',
1520
+ jpg: 'image/jpeg',
1521
+ webp: 'image/webp',
1522
+ svg: 'image/svg+xml',
1523
+ }[format];
1524
+
1525
+ return new Response(imageBuffer, {
1526
+ headers: {
1527
+ 'Content-Type': contentType,
1528
+ 'X-Render-Duration': `${duration}ms`,
1529
+ 'X-Render-Id': generateId.job(),
1530
+ },
1531
+ });
1532
+ });
1533
+
1534
+ // POST /render/async - Asynchronous video rendering
1535
+ render.post('/async', requireScopes('render:write'), async (c) => {
1536
+ const user = c.get('user');
1537
+ const body = await c.req.json();
1538
+
1539
+ const parsed = videoRenderSchema.safeParse(body);
1540
+ if (!parsed.success) {
1541
+ return c.json({ error: 'Validation failed', details: parsed.error.flatten() }, 400);
1542
+ }
1543
+
1544
+ const { template, version, props, format, webhook } = parsed.data;
1545
+
1546
+ // Check usage limits
1547
+ const org = await c.env.DB.prepare(`
1548
+ SELECT video_renders_limit, video_renders_used
1549
+ FROM organizations WHERE id = ?
1550
+ `).bind(user.org).first<any>();
1551
+
1552
+ if (org.video_renders_used >= org.video_renders_limit) {
1553
+ return c.json({ error: 'Video render limit exceeded', limit: org.video_renders_limit }, 403);
1554
+ }
1555
+
1556
+ // Get template
1557
+ const tpl = await getTemplate(c.env, template, version, user.org);
1558
+ if (!tpl) {
1559
+ return c.json({ error: 'Template not found' }, 404);
1560
+ }
1561
+
1562
+ // Create job
1563
+ const jobId = generateId.job();
1564
+ const meta = JSON.parse(tpl.meta);
1565
+ const totalFrames = meta.video ? meta.video.fps * meta.video.duration : 0;
1566
+
1567
+ await c.env.DB.prepare(`
1568
+ INSERT INTO render_jobs (id, org_id, user_id, template_id, props, format, total_frames, webhook_url)
1569
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1570
+ `).bind(jobId, user.org, user.sub, tpl.id, JSON.stringify(props), format, totalFrames, webhook || null).run();
1571
+
1572
+ // Dispatch to container via Durable Object
1573
+ const jobDO = c.env.RENDER_JOBS.get(c.env.RENDER_JOBS.idFromName(jobId));
1574
+ await jobDO.fetch('http://internal/start', {
1575
+ method: 'POST',
1576
+ body: JSON.stringify({
1577
+ jobId,
1578
+ templateId: tpl.id,
1579
+ version: tpl.version,
1580
+ props,
1581
+ format,
1582
+ meta,
1583
+ }),
1584
+ });
1585
+
1586
+ // Increment usage
1587
+ await c.env.DB.prepare(
1588
+ 'UPDATE organizations SET video_renders_used = video_renders_used + 1 WHERE id = ?'
1589
+ ).bind(user.org).run();
1590
+
1591
+ return c.json({
1592
+ jobId,
1593
+ status: 'queued',
1594
+ totalFrames,
1595
+ estimatedDuration: totalFrames * 100, // rough estimate
1596
+ statusUrl: `/render/${jobId}`,
1597
+ createdAt: new Date().toISOString(),
1598
+ }, 202);
1599
+ });
1600
+
1601
+ // GET /render/:jobId - Get job status
1602
+ render.get('/:jobId', async (c) => {
1603
+ const user = c.get('user');
1604
+ const jobId = c.req.param('jobId');
1605
+
1606
+ const job = await c.env.DB.prepare(`
1607
+ SELECT * FROM render_jobs WHERE id = ? AND org_id = ?
1608
+ `).bind(jobId, user.org).first();
1609
+
1610
+ if (!job) {
1611
+ return c.json({ error: 'Job not found' }, 404);
1612
+ }
1613
+
1614
+ const response: any = {
1615
+ jobId: job.id,
1616
+ status: job.status,
1617
+ progress: job.progress,
1618
+ currentFrame: job.current_frame,
1619
+ totalFrames: job.total_frames,
1620
+ createdAt: job.created_at,
1621
+ };
1622
+
1623
+ if (job.status === 'processing') {
1624
+ response.startedAt = job.started_at;
1625
+ }
1626
+
1627
+ if (job.status === 'completed') {
1628
+ response.outputUrl = `https://renders.loopwind.dev/${job.output_key}`;
1629
+ response.expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
1630
+ response.completedAt = job.completed_at;
1631
+ }
1632
+
1633
+ if (job.status === 'failed') {
1634
+ response.error = job.error;
1635
+ }
1636
+
1637
+ return c.json(response);
1638
+ });
1639
+
1640
+ // Helper functions
1641
+ async function getTemplate(env: Env, slugOrId: string, version: string | undefined, orgId: string) {
1642
+ let template;
1643
+
1644
+ if (slugOrId.startsWith('tpl_')) {
1645
+ template = await env.DB.prepare(`
1646
+ SELECT t.*, tv.version, tv.files
1647
+ FROM templates t
1648
+ JOIN template_versions tv ON tv.template_id = t.id
1649
+ WHERE t.id = ? AND (t.is_private = 0 OR t.org_id = ?)
1650
+ ORDER BY tv.published_at DESC LIMIT 1
1651
+ `).bind(slugOrId, orgId).first();
1652
+ } else {
1653
+ const versionClause = version
1654
+ ? 'AND tv.version = ?'
1655
+ : '';
1656
+ const params = version
1657
+ ? [slugOrId, orgId, version]
1658
+ : [slugOrId, orgId];
1659
+
1660
+ template = await env.DB.prepare(`
1661
+ SELECT t.*, tv.version, tv.files
1662
+ FROM templates t
1663
+ JOIN template_versions tv ON tv.template_id = t.id
1664
+ WHERE t.slug = ? AND (t.is_private = 0 OR t.org_id = ?)
1665
+ ${versionClause}
1666
+ ORDER BY tv.published_at DESC LIMIT 1
1667
+ `).bind(...params).first();
1668
+ }
1669
+
1670
+ return template;
1671
+ }
1672
+
1673
+ async function loadTemplateFromR2(env: Env, template: any): Promise<string> {
1674
+ const files = JSON.parse(template.files);
1675
+ const mainFile = files.find((f: any) => f.path === 'template.tsx');
1676
+
1677
+ if (!mainFile) {
1678
+ throw new Error('Template file not found');
1679
+ }
1680
+
1681
+ const object = await env.STORAGE.get(mainFile.r2Key);
1682
+ if (!object) {
1683
+ throw new Error('Template file not found in storage');
1684
+ }
1685
+
1686
+ return await object.text();
1687
+ }
1688
+
1689
+ function getNextMonthReset(): string {
1690
+ const now = new Date();
1691
+ return new Date(now.getFullYear(), now.getMonth() + 1, 1).toISOString();
1692
+ }
1693
+
1694
+ export default render;
1695
+ ```
1696
+
1697
+ ### 4.2 Render Job Durable Object
1698
+
1699
+ ```typescript
1700
+ // src/durable-objects/RenderJobOrchestrator.ts
1701
+ import { DurableObject } from 'cloudflare:workers';
1702
+ import type { Env } from '../types/env';
1703
+
1704
+ export class RenderJobOrchestrator extends DurableObject {
1705
+ constructor(ctx: DurableObjectState, env: Env) {
1706
+ super(ctx, env);
1707
+ }
1708
+
1709
+ async fetch(request: Request): Promise<Response> {
1710
+ const url = new URL(request.url);
1711
+
1712
+ if (url.pathname === '/start' && request.method === 'POST') {
1713
+ return this.startJob(request);
1714
+ }
1715
+
1716
+ if (url.pathname === '/progress' && request.method === 'POST') {
1717
+ return this.updateProgress(request);
1718
+ }
1719
+
1720
+ if (url.pathname === '/complete' && request.method === 'POST') {
1721
+ return this.completeJob(request);
1722
+ }
1723
+
1724
+ if (url.pathname === '/fail' && request.method === 'POST') {
1725
+ return this.failJob(request);
1726
+ }
1727
+
1728
+ return new Response('Not found', { status: 404 });
1729
+ }
1730
+
1731
+ async startJob(request: Request): Promise<Response> {
1732
+ const { jobId, templateId, version, props, format, meta } = await request.json();
1733
+
1734
+ // Store job state
1735
+ await this.ctx.storage.put('job', {
1736
+ jobId,
1737
+ templateId,
1738
+ version,
1739
+ props,
1740
+ format,
1741
+ meta,
1742
+ status: 'processing',
1743
+ progress: 0,
1744
+ startedAt: Date.now(),
1745
+ });
1746
+
1747
+ // Update DB
1748
+ await this.env.DB.prepare(
1749
+ 'UPDATE render_jobs SET status = ?, started_at = ? WHERE id = ?'
1750
+ ).bind('processing', new Date().toISOString(), jobId).run();
1751
+
1752
+ // Start container
1753
+ const container = await getContainer(this.env.VIDEO_RENDERER, jobId);
1754
+
1755
+ // Fire and forget - container will call back with progress/completion
1756
+ container.fetch('http://container/render', {
1757
+ method: 'POST',
1758
+ body: JSON.stringify({
1759
+ jobId,
1760
+ templateId,
1761
+ version,
1762
+ props,
1763
+ format,
1764
+ meta,
1765
+ callbackUrl: `https://api.loopwind.dev/internal/jobs/${jobId}`,
1766
+ }),
1767
+ });
1768
+
1769
+ return new Response(JSON.stringify({ status: 'started' }));
1770
+ }
1771
+
1772
+ async updateProgress(request: Request): Promise<Response> {
1773
+ const { progress, currentFrame } = await request.json();
1774
+ const job = await this.ctx.storage.get('job') as any;
1775
+
1776
+ job.progress = progress;
1777
+ job.currentFrame = currentFrame;
1778
+ await this.ctx.storage.put('job', job);
1779
+
1780
+ // Update DB
1781
+ await this.env.DB.prepare(
1782
+ 'UPDATE render_jobs SET progress = ?, current_frame = ? WHERE id = ?'
1783
+ ).bind(progress, currentFrame, job.jobId).run();
1784
+
1785
+ return new Response(JSON.stringify({ status: 'updated' }));
1786
+ }
1787
+
1788
+ async completeJob(request: Request): Promise<Response> {
1789
+ const { outputKey } = await request.json();
1790
+ const job = await this.ctx.storage.get('job') as any;
1791
+
1792
+ // Update DB
1793
+ await this.env.DB.prepare(`
1794
+ UPDATE render_jobs
1795
+ SET status = 'completed', progress = 1, output_key = ?, completed_at = ?
1796
+ WHERE id = ?
1797
+ `).bind(outputKey, new Date().toISOString(), job.jobId).run();
1798
+
1799
+ // Fire webhook if configured
1800
+ const dbJob = await this.env.DB.prepare(
1801
+ 'SELECT webhook_url FROM render_jobs WHERE id = ?'
1802
+ ).bind(job.jobId).first<any>();
1803
+
1804
+ if (dbJob?.webhook_url) {
1805
+ await fetch(dbJob.webhook_url, {
1806
+ method: 'POST',
1807
+ headers: { 'Content-Type': 'application/json' },
1808
+ body: JSON.stringify({
1809
+ event: 'render.completed',
1810
+ jobId: job.jobId,
1811
+ status: 'completed',
1812
+ outputUrl: `https://renders.loopwind.dev/${outputKey}`,
1813
+ timestamp: new Date().toISOString(),
1814
+ }),
1815
+ });
1816
+
1817
+ await this.env.DB.prepare(
1818
+ 'UPDATE render_jobs SET webhook_sent = 1 WHERE id = ?'
1819
+ ).bind(job.jobId).run();
1820
+ }
1821
+
1822
+ return new Response(JSON.stringify({ status: 'completed' }));
1823
+ }
1824
+
1825
+ async failJob(request: Request): Promise<Response> {
1826
+ const { error } = await request.json();
1827
+ const job = await this.ctx.storage.get('job') as any;
1828
+
1829
+ await this.env.DB.prepare(`
1830
+ UPDATE render_jobs SET status = 'failed', error = ? WHERE id = ?
1831
+ `).bind(error, job.jobId).run();
1832
+
1833
+ return new Response(JSON.stringify({ status: 'failed' }));
1834
+ }
1835
+ }
1836
+ ```
1837
+
1838
+ ---
1839
+
1840
+ ## Phase 5: Video Rendering Container
1841
+
1842
+ ### 5.1 Container Dockerfile
1843
+
1844
+ ```dockerfile
1845
+ # container/Dockerfile
1846
+ FROM node:20-slim
1847
+
1848
+ # Install FFmpeg
1849
+ RUN apt-get update && \
1850
+ apt-get install -y ffmpeg && \
1851
+ rm -rf /var/lib/apt/lists/*
1852
+
1853
+ WORKDIR /app
1854
+
1855
+ # Copy package files
1856
+ COPY package*.json ./
1857
+ RUN npm ci --production
1858
+
1859
+ # Copy application
1860
+ COPY . .
1861
+
1862
+ # Build TypeScript
1863
+ RUN npm run build
1864
+
1865
+ EXPOSE 8080
1866
+
1867
+ CMD ["node", "dist/render-server.js"]
1868
+ ```
1869
+
1870
+ ### 5.2 Container Render Server
1871
+
1872
+ ```typescript
1873
+ // container/src/render-server.ts
1874
+ import express from 'express';
1875
+ import { renderVideo } from './video-renderer';
1876
+
1877
+ const app = express();
1878
+ app.use(express.json({ limit: '50mb' }));
1879
+
1880
+ app.post('/render', async (req, res) => {
1881
+ const { jobId, templateId, version, props, format, meta, callbackUrl } = req.body;
1882
+
1883
+ console.log(`Starting render job ${jobId}`);
1884
+
1885
+ try {
1886
+ // Render video with progress callbacks
1887
+ const outputPath = await renderVideo({
1888
+ jobId,
1889
+ templateId,
1890
+ version,
1891
+ props,
1892
+ format,
1893
+ meta,
1894
+ onProgress: async (progress, currentFrame) => {
1895
+ // Report progress to API
1896
+ await fetch(`${callbackUrl}/progress`, {
1897
+ method: 'POST',
1898
+ headers: { 'Content-Type': 'application/json' },
1899
+ body: JSON.stringify({ progress, currentFrame }),
1900
+ });
1901
+ },
1902
+ });
1903
+
1904
+ // Upload to R2
1905
+ const outputKey = `${jobId}.${format}`;
1906
+ await uploadToR2(outputPath, outputKey);
1907
+
1908
+ // Report completion
1909
+ await fetch(`${callbackUrl}/complete`, {
1910
+ method: 'POST',
1911
+ headers: { 'Content-Type': 'application/json' },
1912
+ body: JSON.stringify({ outputKey }),
1913
+ });
1914
+
1915
+ res.json({ status: 'completed', outputKey });
1916
+ } catch (error: any) {
1917
+ console.error(`Render failed for job ${jobId}:`, error);
1918
+
1919
+ // Report failure
1920
+ await fetch(`${callbackUrl}/fail`, {
1921
+ method: 'POST',
1922
+ headers: { 'Content-Type': 'application/json' },
1923
+ body: JSON.stringify({ error: error.message }),
1924
+ });
1925
+
1926
+ res.status(500).json({ error: error.message });
1927
+ }
1928
+ });
1929
+
1930
+ app.get('/health', (req, res) => {
1931
+ res.json({ status: 'healthy' });
1932
+ });
1933
+
1934
+ const PORT = process.env.PORT || 8080;
1935
+ app.listen(PORT, () => {
1936
+ console.log(`Render server listening on port ${PORT}`);
1937
+ });
1938
+ ```
1939
+
1940
+ ---
1941
+
1942
+ ## Phase 6: Organization & User Management
1943
+
1944
+ ### 6.1 Organization Routes
1945
+
1946
+ ```typescript
1947
+ // src/routes/organizations.ts
1948
+ import { Hono } from 'hono';
1949
+ import { z } from 'zod';
1950
+ import type { Env } from '../types/env';
1951
+ import { authMiddleware, requireRole } from '../middleware/auth';
1952
+ import { generateId, generateInviteToken } from '../lib/id';
1953
+
1954
+ const orgs = new Hono<{ Bindings: Env }>();
1955
+
1956
+ orgs.use('*', authMiddleware);
1957
+
1958
+ // GET /organizations/current - Get current org
1959
+ orgs.get('/current', async (c) => {
1960
+ const user = c.get('user');
1961
+
1962
+ const org = await c.env.DB.prepare(`
1963
+ SELECT o.*,
1964
+ (SELECT COUNT(*) FROM users WHERE org_id = o.id) as member_count,
1965
+ (SELECT COUNT(*) FROM templates WHERE org_id = o.id) as template_count
1966
+ FROM organizations o
1967
+ WHERE o.id = ?
1968
+ `).bind(user.org).first();
1969
+
1970
+ return c.json(org);
1971
+ });
1972
+
1973
+ // PUT /organizations/current - Update org
1974
+ orgs.put('/current', requireRole('owner', 'admin'), async (c) => {
1975
+ const user = c.get('user');
1976
+ const { name } = await c.req.json();
1977
+
1978
+ if (name) {
1979
+ await c.env.DB.prepare(
1980
+ 'UPDATE organizations SET name = ?, updated_at = datetime("now") WHERE id = ?'
1981
+ ).bind(name, user.org).run();
1982
+ }
1983
+
1984
+ return c.json({ success: true });
1985
+ });
1986
+
1987
+ // GET /organizations/current/members - List members
1988
+ orgs.get('/current/members', async (c) => {
1989
+ const user = c.get('user');
1990
+
1991
+ const members = await c.env.DB.prepare(`
1992
+ SELECT id, email, username, name, role, last_login_at, created_at
1993
+ FROM users WHERE org_id = ?
1994
+ `).bind(user.org).all();
1995
+
1996
+ return c.json({ members: members.results });
1997
+ });
1998
+
1999
+ // POST /organizations/current/invitations - Invite user
2000
+ orgs.post('/current/invitations', requireRole('owner', 'admin'), async (c) => {
2001
+ const user = c.get('user');
2002
+ const { email, role = 'member' } = await c.req.json();
2003
+
2004
+ // Check member limit
2005
+ const org = await c.env.DB.prepare(
2006
+ 'SELECT members_limit FROM organizations WHERE id = ?'
2007
+ ).bind(user.org).first<any>();
2008
+
2009
+ const memberCount = await c.env.DB.prepare(
2010
+ 'SELECT COUNT(*) as count FROM users WHERE org_id = ?'
2011
+ ).bind(user.org).first<{ count: number }>();
2012
+
2013
+ if (memberCount!.count >= org.members_limit) {
2014
+ return c.json({ error: 'Member limit reached' }, 403);
2015
+ }
2016
+
2017
+ // Check if user already exists in org
2018
+ const existing = await c.env.DB.prepare(
2019
+ 'SELECT id FROM users WHERE email = ? AND org_id = ?'
2020
+ ).bind(email, user.org).first();
2021
+
2022
+ if (existing) {
2023
+ return c.json({ error: 'User already in organization' }, 409);
2024
+ }
2025
+
2026
+ // Create invitation
2027
+ const inviteId = generateId.invitation();
2028
+ const token = generateInviteToken();
2029
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
2030
+
2031
+ await c.env.DB.prepare(`
2032
+ INSERT INTO invitations (id, org_id, invited_by, email, role, token, expires_at)
2033
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2034
+ `).bind(inviteId, user.org, user.sub, email, role, token, expiresAt).run();
2035
+
2036
+ // TODO: Send invitation email
2037
+
2038
+ return c.json({
2039
+ id: inviteId,
2040
+ email,
2041
+ role,
2042
+ inviteUrl: `https://loopwind.dev/invite/${token}`,
2043
+ expiresAt,
2044
+ }, 201);
2045
+ });
2046
+
2047
+ // DELETE /organizations/current/members/:userId - Remove member
2048
+ orgs.delete('/current/members/:userId', requireRole('owner', 'admin'), async (c) => {
2049
+ const user = c.get('user');
2050
+ const userId = c.req.param('userId');
2051
+
2052
+ // Can't remove yourself
2053
+ if (userId === user.sub) {
2054
+ return c.json({ error: 'Cannot remove yourself' }, 400);
2055
+ }
2056
+
2057
+ // Can't remove owner
2058
+ const targetUser = await c.env.DB.prepare(
2059
+ 'SELECT role FROM users WHERE id = ? AND org_id = ?'
2060
+ ).bind(userId, user.org).first<{ role: string }>();
2061
+
2062
+ if (!targetUser) {
2063
+ return c.json({ error: 'User not found' }, 404);
2064
+ }
2065
+
2066
+ if (targetUser.role === 'owner') {
2067
+ return c.json({ error: 'Cannot remove owner' }, 403);
2068
+ }
2069
+
2070
+ await c.env.DB.prepare('DELETE FROM users WHERE id = ?').bind(userId).run();
2071
+
2072
+ return c.json({ success: true });
2073
+ });
2074
+
2075
+ export default orgs;
2076
+ ```
2077
+
2078
+ ### 6.2 API Key Routes
2079
+
2080
+ ```typescript
2081
+ // src/routes/keys.ts
2082
+ import { Hono } from 'hono';
2083
+ import { z } from 'zod';
2084
+ import type { Env } from '../types/env';
2085
+ import { authMiddleware } from '../middleware/auth';
2086
+ import { generateId, generateApiKey } from '../lib/id';
2087
+ import { hashPassword } from '../lib/password';
2088
+
2089
+ const keys = new Hono<{ Bindings: Env }>();
2090
+
2091
+ keys.use('*', authMiddleware);
2092
+
2093
+ const createKeySchema = z.object({
2094
+ name: z.string().min(1).max(100),
2095
+ scopes: z.array(z.string()).default(['render:read', 'render:write', 'templates:read']),
2096
+ expiresIn: z.number().optional(), // Days until expiration
2097
+ });
2098
+
2099
+ // GET /keys - List API keys
2100
+ keys.get('/', async (c) => {
2101
+ const user = c.get('user');
2102
+
2103
+ const apiKeys = await c.env.DB.prepare(`
2104
+ SELECT id, name, key_prefix, scopes, last_used_at, expires_at, created_at
2105
+ FROM api_keys WHERE org_id = ?
2106
+ `).bind(user.org).all();
2107
+
2108
+ return c.json({
2109
+ keys: apiKeys.results.map((k: any) => ({
2110
+ ...k,
2111
+ scopes: JSON.parse(k.scopes),
2112
+ })),
2113
+ });
2114
+ });
2115
+
2116
+ // POST /keys - Create API key
2117
+ keys.post('/', async (c) => {
2118
+ const user = c.get('user');
2119
+ const body = await c.req.json();
2120
+
2121
+ const parsed = createKeySchema.safeParse(body);
2122
+ if (!parsed.success) {
2123
+ return c.json({ error: 'Validation failed' }, 400);
2124
+ }
2125
+
2126
+ const { name, scopes, expiresIn } = parsed.data;
2127
+
2128
+ const keyId = generateId.key();
2129
+ const { key, prefix } = generateApiKey();
2130
+ const keyHash = await hashPassword(key);
2131
+
2132
+ const expiresAt = expiresIn
2133
+ ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000).toISOString()
2134
+ : null;
2135
+
2136
+ await c.env.DB.prepare(`
2137
+ INSERT INTO api_keys (id, org_id, user_id, name, key_hash, key_prefix, scopes, expires_at)
2138
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2139
+ `).bind(keyId, user.org, user.sub, name, keyHash, prefix, JSON.stringify(scopes), expiresAt).run();
2140
+
2141
+ return c.json({
2142
+ id: keyId,
2143
+ key, // Only returned once!
2144
+ name,
2145
+ prefix,
2146
+ scopes,
2147
+ expiresAt,
2148
+ createdAt: new Date().toISOString(),
2149
+ }, 201);
2150
+ });
2151
+
2152
+ // DELETE /keys/:id - Revoke API key
2153
+ keys.delete('/:id', async (c) => {
2154
+ const user = c.get('user');
2155
+ const keyId = c.req.param('id');
2156
+
2157
+ const result = await c.env.DB.prepare(
2158
+ 'DELETE FROM api_keys WHERE id = ? AND org_id = ?'
2159
+ ).bind(keyId, user.org).run();
2160
+
2161
+ if (result.meta.changes === 0) {
2162
+ return c.json({ error: 'API key not found' }, 404);
2163
+ }
2164
+
2165
+ return c.json({ success: true });
2166
+ });
2167
+
2168
+ export default keys;
2169
+ ```
2170
+
2171
+ ---
2172
+
2173
+ ## Phase 7: Main Worker Entry Point
2174
+
2175
+ ### 7.1 Main Application
2176
+
2177
+ ```typescript
2178
+ // src/index.ts
2179
+ import { Hono } from 'hono';
2180
+ import { cors } from 'hono/cors';
2181
+ import { logger } from 'hono/logger';
2182
+ import type { Env } from './types/env';
2183
+
2184
+ import auth from './routes/auth';
2185
+ import organizations from './routes/organizations';
2186
+ import templates from './routes/templates';
2187
+ import render from './routes/render';
2188
+ import keys from './routes/keys';
2189
+
2190
+ import { rateLimitMiddleware } from './middleware/rateLimit';
2191
+ import { RenderJobOrchestrator } from './durable-objects/RenderJobOrchestrator';
2192
+
2193
+ const app = new Hono<{ Bindings: Env }>();
2194
+
2195
+ // Global middleware
2196
+ app.use('*', logger());
2197
+ app.use('*', cors({
2198
+ origin: ['https://loopwind.dev', 'http://localhost:3000'],
2199
+ credentials: true,
2200
+ }));
2201
+
2202
+ // Health check
2203
+ app.get('/health', (c) => c.json({ status: 'ok' }));
2204
+
2205
+ // Public routes
2206
+ app.route('/auth', auth);
2207
+
2208
+ // Protected routes with rate limiting
2209
+ app.use('/organizations/*', rateLimitMiddleware);
2210
+ app.use('/templates/*', rateLimitMiddleware);
2211
+ app.use('/render/*', rateLimitMiddleware);
2212
+ app.use('/keys/*', rateLimitMiddleware);
2213
+
2214
+ app.route('/organizations', organizations);
2215
+ app.route('/templates', templates);
2216
+ app.route('/render', render);
2217
+ app.route('/keys', keys);
2218
+
2219
+ // Internal routes (for container callbacks)
2220
+ app.post('/internal/jobs/:jobId/progress', async (c) => {
2221
+ const jobId = c.req.param('jobId');
2222
+ const jobDO = c.env.RENDER_JOBS.get(c.env.RENDER_JOBS.idFromName(jobId));
2223
+ return jobDO.fetch('http://internal/progress', {
2224
+ method: 'POST',
2225
+ body: JSON.stringify(await c.req.json()),
2226
+ });
2227
+ });
2228
+
2229
+ app.post('/internal/jobs/:jobId/complete', async (c) => {
2230
+ const jobId = c.req.param('jobId');
2231
+ const jobDO = c.env.RENDER_JOBS.get(c.env.RENDER_JOBS.idFromName(jobId));
2232
+ return jobDO.fetch('http://internal/complete', {
2233
+ method: 'POST',
2234
+ body: JSON.stringify(await c.req.json()),
2235
+ });
2236
+ });
2237
+
2238
+ app.post('/internal/jobs/:jobId/fail', async (c) => {
2239
+ const jobId = c.req.param('jobId');
2240
+ const jobDO = c.env.RENDER_JOBS.get(c.env.RENDER_JOBS.idFromName(jobId));
2241
+ return jobDO.fetch('http://internal/fail', {
2242
+ method: 'POST',
2243
+ body: JSON.stringify(await c.req.json()),
2244
+ });
2245
+ });
2246
+
2247
+ // 404 handler
2248
+ app.notFound((c) => c.json({ error: 'Not found' }, 404));
2249
+
2250
+ // Error handler
2251
+ app.onError((err, c) => {
2252
+ console.error('Unhandled error:', err);
2253
+ return c.json({ error: 'Internal server error' }, 500);
2254
+ });
2255
+
2256
+ // Export for Cloudflare Workers
2257
+ export default app;
2258
+
2259
+ // Export Durable Object
2260
+ export { RenderJobOrchestrator };
2261
+ ```
2262
+
2263
+ ---
2264
+
2265
+ ## Phase 8: CLI Authentication Commands
2266
+
2267
+ ### 8.1 Login Command
2268
+
2269
+ ```typescript
2270
+ // CLI: src/commands/login.ts
2271
+ import { Command } from 'commander';
2272
+ import http from 'http';
2273
+ import open from 'open';
2274
+ import { saveCredentials } from '../lib/config';
2275
+
2276
+ export const loginCommand = new Command('login')
2277
+ .description('Login to loopwind.dev')
2278
+ .option('--no-browser', 'Print login URL instead of opening browser')
2279
+ .action(async (options) => {
2280
+ // Start local server to receive callback
2281
+ const server = http.createServer();
2282
+ const port = await getAvailablePort(3333);
2283
+
2284
+ const callbackUrl = `http://localhost:${port}/callback`;
2285
+ const loginUrl = `https://loopwind.dev/cli-login?callback=${encodeURIComponent(callbackUrl)}`;
2286
+
2287
+ return new Promise((resolve, reject) => {
2288
+ server.on('request', async (req, res) => {
2289
+ if (req.url?.startsWith('/callback')) {
2290
+ const url = new URL(req.url, `http://localhost:${port}`);
2291
+ const token = url.searchParams.get('token');
2292
+ const refreshToken = url.searchParams.get('refreshToken');
2293
+ const user = JSON.parse(url.searchParams.get('user') || '{}');
2294
+
2295
+ if (token && refreshToken) {
2296
+ await saveCredentials({
2297
+ token,
2298
+ refreshToken,
2299
+ user,
2300
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
2301
+ });
2302
+
2303
+ res.writeHead(200, { 'Content-Type': 'text/html' });
2304
+ res.end('<h1>Success!</h1><p>You can close this window.</p>');
2305
+
2306
+ console.log(`✓ Logged in as ${user.email}`);
2307
+ server.close();
2308
+ resolve(undefined);
2309
+ } else {
2310
+ res.writeHead(400);
2311
+ res.end('Login failed');
2312
+ reject(new Error('Login failed'));
2313
+ }
2314
+ }
2315
+ });
2316
+
2317
+ server.listen(port, () => {
2318
+ if (options.browser) {
2319
+ console.log('Opening browser for authentication...');
2320
+ open(loginUrl);
2321
+ } else {
2322
+ console.log(`Open this URL to login:\n${loginUrl}`);
2323
+ }
2324
+ console.log('Waiting for authentication...');
2325
+ });
2326
+
2327
+ // Timeout after 5 minutes
2328
+ setTimeout(() => {
2329
+ server.close();
2330
+ reject(new Error('Login timed out'));
2331
+ }, 5 * 60 * 1000);
2332
+ });
2333
+ });
2334
+
2335
+ export const logoutCommand = new Command('logout')
2336
+ .description('Logout from loopwind.dev')
2337
+ .action(async () => {
2338
+ const { clearCredentials } = await import('../lib/config');
2339
+ await clearCredentials();
2340
+ console.log('✓ Logged out');
2341
+ });
2342
+
2343
+ export const whoamiCommand = new Command('whoami')
2344
+ .description('Show current user')
2345
+ .action(async () => {
2346
+ const { getCredentials } = await import('../lib/config');
2347
+ const creds = await getCredentials();
2348
+
2349
+ if (!creds) {
2350
+ console.log('Not logged in');
2351
+ return;
2352
+ }
2353
+
2354
+ console.log(`Logged in as: ${creds.user.email}`);
2355
+ console.log(`Organization: ${creds.user.org_name || 'N/A'}`);
2356
+ });
2357
+ ```
2358
+
2359
+ ---
2360
+
2361
+ ## Phase 9: Dashboard UI (website/)
2362
+
2363
+ The dashboard UI lives in the existing Astro website at `app.loopwind.dev`.
2364
+
2365
+ ### 9.1 Dashboard Structure
2366
+
2367
+ ```
2368
+ website/
2369
+ ├── src/
2370
+ │ ├── pages/
2371
+ │ │ ├── index.astro # Marketing homepage
2372
+ │ │ ├── docs/ # Documentation
2373
+ │ │ └── app/ # Dashboard (protected)
2374
+ │ │ ├── index.astro # Dashboard home
2375
+ │ │ ├── login.astro # Login page
2376
+ │ │ ├── register.astro # Registration page
2377
+ │ │ ├── templates/
2378
+ │ │ │ ├── index.astro # Template list
2379
+ │ │ │ └── [slug].astro # Template detail
2380
+ │ │ ├── renders/
2381
+ │ │ │ ├── index.astro # Render history
2382
+ │ │ │ └── [id].astro # Render detail
2383
+ │ │ ├── settings/
2384
+ │ │ │ ├── index.astro # Account settings
2385
+ │ │ │ ├── api-keys.astro # API key management
2386
+ │ │ │ └── team.astro # Team/org management
2387
+ │ │ └── billing.astro # Billing/plan
2388
+ │ ├── components/
2389
+ │ │ └── app/ # Dashboard components
2390
+ │ │ ├── Sidebar.astro
2391
+ │ │ ├── Header.astro
2392
+ │ │ ├── TemplateCard.astro
2393
+ │ │ └── RenderPreview.astro
2394
+ │ ├── layouts/
2395
+ │ │ ├── Layout.astro # Marketing layout
2396
+ │ │ └── AppLayout.astro # Dashboard layout
2397
+ │ └── lib/
2398
+ │ └── api.ts # API client
2399
+ ```
2400
+
2401
+ ### 9.2 API Client
2402
+
2403
+ ```typescript
2404
+ // website/src/lib/api.ts
2405
+ const API_BASE = import.meta.env.PUBLIC_API_URL || 'https://api.loopwind.dev';
2406
+
2407
+ interface ApiOptions {
2408
+ method?: string;
2409
+ body?: any;
2410
+ token?: string;
2411
+ }
2412
+
2413
+ export async function api<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
2414
+ const { method = 'GET', body, token } = options;
2415
+
2416
+ const headers: Record<string, string> = {
2417
+ 'Content-Type': 'application/json',
2418
+ };
2419
+
2420
+ if (token) {
2421
+ headers['Authorization'] = `Bearer ${token}`;
2422
+ }
2423
+
2424
+ const response = await fetch(`${API_BASE}${endpoint}`, {
2425
+ method,
2426
+ headers,
2427
+ body: body ? JSON.stringify(body) : undefined,
2428
+ credentials: 'include',
2429
+ });
2430
+
2431
+ if (!response.ok) {
2432
+ const error = await response.json();
2433
+ throw new Error(error.error || 'API request failed');
2434
+ }
2435
+
2436
+ return response.json();
2437
+ }
2438
+
2439
+ // Auth helpers
2440
+ export const auth = {
2441
+ login: (email: string, password: string) =>
2442
+ api<{ accessToken: string; refreshToken: string; user: any }>('/auth/login', {
2443
+ method: 'POST',
2444
+ body: { email, password },
2445
+ }),
2446
+
2447
+ register: (data: { email: string; password: string; username: string; name?: string; orgName: string }) =>
2448
+ api<{ accessToken: string; refreshToken: string; user: any; organization: any }>('/auth/register', {
2449
+ method: 'POST',
2450
+ body: data,
2451
+ }),
2452
+
2453
+ logout: (token: string) =>
2454
+ api('/auth/logout', { method: 'POST', token }),
2455
+
2456
+ refresh: (refreshToken: string) =>
2457
+ api<{ accessToken: string; refreshToken: string }>('/auth/refresh', {
2458
+ method: 'POST',
2459
+ body: { refreshToken },
2460
+ }),
2461
+ };
2462
+
2463
+ // Templates
2464
+ export const templates = {
2465
+ list: (token: string, params?: { q?: string; type?: string; page?: number }) =>
2466
+ api<{ templates: any[]; total: number }>(`/templates?${new URLSearchParams(params as any)}`, { token }),
2467
+
2468
+ get: (token: string, slug: string) =>
2469
+ api<any>(`/templates/${slug}`, { token }),
2470
+ };
2471
+
2472
+ // Renders
2473
+ export const renders = {
2474
+ create: (token: string, data: { template: string; props: any; format: string }) =>
2475
+ api('/render', { method: 'POST', token, body: data }),
2476
+
2477
+ createAsync: (token: string, data: { template: string; props: any; format: string; webhook?: string }) =>
2478
+ api<{ jobId: string; status: string }>('/render/async', { method: 'POST', token, body: data }),
2479
+
2480
+ getJob: (token: string, jobId: string) =>
2481
+ api<any>(`/render/${jobId}`, { token }),
2482
+ };
2483
+
2484
+ // API Keys
2485
+ export const apiKeys = {
2486
+ list: (token: string) =>
2487
+ api<{ keys: any[] }>('/keys', { token }),
2488
+
2489
+ create: (token: string, data: { name: string; scopes?: string[] }) =>
2490
+ api<{ id: string; key: string; name: string }>('/keys', { method: 'POST', token, body: data }),
2491
+
2492
+ revoke: (token: string, id: string) =>
2493
+ api(`/keys/${id}`, { method: 'DELETE', token }),
2494
+ };
2495
+ ```
2496
+
2497
+ ### 9.3 Auth State Management
2498
+
2499
+ ```typescript
2500
+ // website/src/lib/auth-store.ts
2501
+ import { atom } from 'nanostores';
2502
+
2503
+ interface AuthState {
2504
+ user: any | null;
2505
+ organization: any | null;
2506
+ accessToken: string | null;
2507
+ refreshToken: string | null;
2508
+ isAuthenticated: boolean;
2509
+ }
2510
+
2511
+ export const authStore = atom<AuthState>({
2512
+ user: null,
2513
+ organization: null,
2514
+ accessToken: null,
2515
+ refreshToken: null,
2516
+ isAuthenticated: false,
2517
+ });
2518
+
2519
+ // Initialize from localStorage (client-side only)
2520
+ export function initAuth() {
2521
+ if (typeof window === 'undefined') return;
2522
+
2523
+ const stored = localStorage.getItem('loopwind_auth');
2524
+ if (stored) {
2525
+ try {
2526
+ const data = JSON.parse(stored);
2527
+ authStore.set({ ...data, isAuthenticated: !!data.accessToken });
2528
+ } catch {
2529
+ localStorage.removeItem('loopwind_auth');
2530
+ }
2531
+ }
2532
+ }
2533
+
2534
+ export function setAuth(data: Partial<AuthState>) {
2535
+ const current = authStore.get();
2536
+ const newState = { ...current, ...data, isAuthenticated: !!data.accessToken };
2537
+ authStore.set(newState);
2538
+
2539
+ if (typeof window !== 'undefined') {
2540
+ localStorage.setItem('loopwind_auth', JSON.stringify(newState));
2541
+ }
2542
+ }
2543
+
2544
+ export function clearAuth() {
2545
+ authStore.set({
2546
+ user: null,
2547
+ organization: null,
2548
+ accessToken: null,
2549
+ refreshToken: null,
2550
+ isAuthenticated: false,
2551
+ });
2552
+
2553
+ if (typeof window !== 'undefined') {
2554
+ localStorage.removeItem('loopwind_auth');
2555
+ }
2556
+ }
2557
+
2558
+ export function getToken(): string | null {
2559
+ return authStore.get().accessToken;
2560
+ }
2561
+ ```
2562
+
2563
+ ### 9.4 Protected Route Middleware
2564
+
2565
+ ```astro
2566
+ ---
2567
+ // website/src/middleware.ts (Astro middleware)
2568
+ import { defineMiddleware } from 'astro:middleware';
2569
+
2570
+ export const onRequest = defineMiddleware(async (context, next) => {
2571
+ const { pathname } = context.url;
2572
+
2573
+ // Check if route requires auth
2574
+ if (pathname.startsWith('/app') && !pathname.startsWith('/app/login') && !pathname.startsWith('/app/register')) {
2575
+ // Check for auth cookie/header
2576
+ const token = context.cookies.get('loopwind_token')?.value;
2577
+
2578
+ if (!token) {
2579
+ return context.redirect('/app/login?redirect=' + encodeURIComponent(pathname));
2580
+ }
2581
+
2582
+ // Optionally validate token with API
2583
+ // context.locals.user = await validateToken(token);
2584
+ }
2585
+
2586
+ return next();
2587
+ });
2588
+ ```
2589
+
2590
+ ### 9.5 Example Dashboard Page
2591
+
2592
+ ```astro
2593
+ ---
2594
+ // website/src/pages/app/templates/index.astro
2595
+ import AppLayout from '../../../layouts/AppLayout.astro';
2596
+ import TemplateCard from '../../../components/app/TemplateCard.astro';
2597
+ import { templates } from '../../../lib/api';
2598
+
2599
+ const token = Astro.cookies.get('loopwind_token')?.value;
2600
+ let templateList = [];
2601
+ let error = null;
2602
+
2603
+ try {
2604
+ const response = await templates.list(token!, {});
2605
+ templateList = response.templates;
2606
+ } catch (e: any) {
2607
+ error = e.message;
2608
+ }
2609
+ ---
2610
+
2611
+ <AppLayout title="Templates">
2612
+ <div class="p-6">
2613
+ <div class="flex justify-between items-center mb-6">
2614
+ <h1 class="text-2xl font-bold">Templates</h1>
2615
+ <a href="/app/templates/new" class="btn btn-primary">
2616
+ Publish Template
2617
+ </a>
2618
+ </div>
2619
+
2620
+ {error && (
2621
+ <div class="alert alert-error mb-4">{error}</div>
2622
+ )}
2623
+
2624
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
2625
+ {templateList.map((template) => (
2626
+ <TemplateCard template={template} />
2627
+ ))}
2628
+ </div>
2629
+
2630
+ {templateList.length === 0 && !error && (
2631
+ <div class="text-center py-12 text-gray-500">
2632
+ <p>No templates yet.</p>
2633
+ <p class="mt-2">
2634
+ Publish your first template using:
2635
+ <code class="bg-gray-100 px-2 py-1 rounded">loopwind publish my-template</code>
2636
+ </p>
2637
+ </div>
2638
+ )}
2639
+ </div>
2640
+ </AppLayout>
2641
+ ```
2642
+
2643
+ ### 9.6 Website Wrangler Config (Updated)
2644
+
2645
+ ```toml
2646
+ # website/wrangler.toml
2647
+ name = "loopwind-website"
2648
+ compatibility_date = "2024-12-01"
2649
+ compatibility_flags = ["nodejs_compat_v2"]
2650
+
2651
+ # Custom domains
2652
+ routes = [
2653
+ { pattern = "loopwind.dev/*", custom_domain = true },
2654
+ { pattern = "app.loopwind.dev/*", custom_domain = true }
2655
+ ]
2656
+
2657
+ [build]
2658
+ command = "npm run build"
2659
+
2660
+ [site]
2661
+ bucket = "./dist"
2662
+
2663
+ # Environment variables
2664
+ [vars]
2665
+ PUBLIC_API_URL = "https://api.loopwind.dev"
2666
+ ```
2667
+
2668
+ ---
2669
+
2670
+ ## Implementation Checklist
2671
+
2672
+ ### Phase 1: Setup
2673
+ - [ ] Create Cloudflare project with Wrangler
2674
+ - [ ] Set up D1 database and run migrations
2675
+ - [ ] Create KV namespaces
2676
+ - [ ] Create R2 buckets
2677
+ - [ ] Configure wrangler.toml
2678
+
2679
+ ### Phase 2: Authentication
2680
+ - [ ] Implement password hashing utilities
2681
+ - [ ] Implement JWT utilities
2682
+ - [ ] Create auth routes (register, login, logout, refresh)
2683
+ - [ ] Create auth middleware
2684
+ - [ ] Create rate limiting middleware
2685
+ - [ ] Test auth flow
2686
+
2687
+ ### Phase 3: Organizations & Users
2688
+ - [ ] Implement organization routes
2689
+ - [ ] Implement user management routes
2690
+ - [ ] Implement invitation system
2691
+ - [ ] Implement API key management
2692
+ - [ ] Test organization flows
2693
+
2694
+ ### Phase 4: Template Publishing
2695
+ - [ ] Implement template publish endpoint
2696
+ - [ ] Implement template listing/search
2697
+ - [ ] Implement template versioning
2698
+ - [ ] Add CLI `loopwind publish` command
2699
+ - [ ] Add CLI `loopwind login/logout` commands
2700
+ - [ ] Test publishing flow
2701
+
2702
+ ### Phase 5: Image Rendering
2703
+ - [ ] Port loopwind renderer to Workers
2704
+ - [ ] Implement sync render endpoint
2705
+ - [ ] Add usage tracking
2706
+ - [ ] Test image rendering
2707
+
2708
+ ### Phase 6: Video Rendering
2709
+ - [ ] Create container Dockerfile
2710
+ - [ ] Implement container render server
2711
+ - [ ] Create Durable Object for job orchestration
2712
+ - [ ] Implement async render endpoint
2713
+ - [ ] Implement job status endpoint
2714
+ - [ ] Implement webhook notifications
2715
+ - [ ] Test video rendering
2716
+
2717
+ ### Phase 7: Production (Platform)
2718
+ - [ ] Set up api.loopwind.dev custom domain
2719
+ - [ ] Configure secrets (JWT_SECRET)
2720
+ - [ ] Set up monitoring (Sentry, analytics)
2721
+ - [ ] Create staging environment
2722
+ - [ ] Deploy platform to production
2723
+
2724
+ ### Phase 8: CLI Commands
2725
+ - [ ] Add `loopwind login` command (browser OAuth flow)
2726
+ - [ ] Add `loopwind logout` command
2727
+ - [ ] Add `loopwind whoami` command
2728
+ - [ ] Add `loopwind publish` command
2729
+ - [ ] Add `loopwind render --remote` flag
2730
+ - [ ] Add `loopwind keys` commands
2731
+ - [ ] Store credentials in ~/.loopwind/credentials.json
2732
+
2733
+ ### Phase 9: Dashboard UI (Website)
2734
+ - [ ] Create AppLayout.astro for dashboard
2735
+ - [ ] Create login/register pages
2736
+ - [ ] Implement API client (website/src/lib/api.ts)
2737
+ - [ ] Implement auth state management (nanostores)
2738
+ - [ ] Create middleware for protected routes
2739
+ - [ ] Build templates list page
2740
+ - [ ] Build template detail page
2741
+ - [ ] Build renders history page
2742
+ - [ ] Build settings pages (account, API keys, team)
2743
+ - [ ] Set up app.loopwind.dev domain
2744
+
2745
+ ---
2746
+
2747
+ ## Cost Estimates
2748
+
2749
+ | Resource | Free Tier | Estimated Monthly (Pro) |
2750
+ |----------|-----------|------------------------|
2751
+ | Workers | 100k req/day | $5 + usage |
2752
+ | D1 | 5M rows read, 100k writes | $5 + usage |
2753
+ | KV | 100k reads, 1k writes | Included |
2754
+ | R2 | 10GB storage, 10M reads | $15/TB + ops |
2755
+ | Containers | - | ~$10-50 (usage based) |
2756
+ | **Total** | **$0** | **~$30-100/month** |
2757
+
2758
+ ---
2759
+
2760
+ ## Security Checklist
2761
+
2762
+ - [ ] All passwords hashed with bcrypt (cost 12)
2763
+ - [ ] JWT tokens expire in 15 minutes
2764
+ - [ ] Refresh tokens stored in KV with TTL
2765
+ - [ ] API keys hashed before storage
2766
+ - [ ] Rate limiting per organization
2767
+ - [ ] CORS configured for allowed origins
2768
+ - [ ] Input validation with Zod on all endpoints
2769
+ - [ ] SQL injection prevented via prepared statements
2770
+ - [ ] Template code sandboxed (no eval, no network)
2771
+ - [ ] R2 signed URLs with expiration
2772
+ - [ ] Audit logging for sensitive operations