loopwind 0.24.0 → 0.24.2
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.
- package/dist/cli.js +0 -6
- package/dist/cli.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +22 -4
- package/dist/commands/init.js.map +1 -1
- package/dist/lib/tailwind.d.ts.map +1 -1
- package/dist/lib/tailwind.js +11 -5
- package/dist/lib/tailwind.js.map +1 -1
- package/package.json +1 -1
- package/plans/PLATFORM.md +1001 -0
- package/plans/PLATFORM_IMPLEMENTATION.md +2772 -0
|
@@ -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
|