loopwind 0.25.6 → 0.25.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/app/.astro/types.d.ts +1 -0
  2. package/app/dist/_astro/callback.Ci5gaEfJ.css +1 -0
  3. package/app/dist/auth/callback/index.html +81 -0
  4. package/app/dist/device/index.html +70 -0
  5. package/app/dist/index.html +327 -0
  6. package/app/package-lock.json +9239 -0
  7. package/app/package.json +23 -0
  8. package/app/wrangler.toml +8 -0
  9. package/dist/cli.js +54 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/login.d.ts +5 -0
  12. package/dist/commands/login.d.ts.map +1 -0
  13. package/dist/commands/login.js +60 -0
  14. package/dist/commands/login.js.map +1 -0
  15. package/dist/commands/logout.d.ts +5 -0
  16. package/dist/commands/logout.d.ts.map +1 -0
  17. package/dist/commands/logout.js +15 -0
  18. package/dist/commands/logout.js.map +1 -0
  19. package/dist/commands/publish.d.ts +10 -0
  20. package/dist/commands/publish.d.ts.map +1 -0
  21. package/dist/commands/publish.js +155 -0
  22. package/dist/commands/publish.js.map +1 -0
  23. package/dist/commands/templates.d.ts +5 -0
  24. package/dist/commands/templates.d.ts.map +1 -0
  25. package/dist/commands/templates.js +60 -0
  26. package/dist/commands/templates.js.map +1 -0
  27. package/dist/commands/unpublish.d.ts +5 -0
  28. package/dist/commands/unpublish.d.ts.map +1 -0
  29. package/dist/commands/unpublish.js +54 -0
  30. package/dist/commands/unpublish.js.map +1 -0
  31. package/dist/commands/whoami.d.ts +5 -0
  32. package/dist/commands/whoami.d.ts.map +1 -0
  33. package/dist/commands/whoami.js +30 -0
  34. package/dist/commands/whoami.js.map +1 -0
  35. package/dist/lib/api.d.ts +92 -0
  36. package/dist/lib/api.d.ts.map +1 -0
  37. package/dist/lib/api.js +149 -0
  38. package/dist/lib/api.js.map +1 -0
  39. package/dist/lib/auth.d.ts +41 -0
  40. package/dist/lib/auth.d.ts.map +1 -0
  41. package/dist/lib/auth.js +89 -0
  42. package/dist/lib/auth.js.map +1 -0
  43. package/dist/lib/bundler.d.ts +18 -0
  44. package/dist/lib/bundler.d.ts.map +1 -0
  45. package/dist/lib/bundler.js +105 -0
  46. package/dist/lib/bundler.js.map +1 -0
  47. package/dist/lib/helpers.d.ts +35 -2
  48. package/dist/lib/helpers.d.ts.map +1 -1
  49. package/dist/lib/helpers.js +91 -13
  50. package/dist/lib/helpers.js.map +1 -1
  51. package/dist/lib/utils.d.ts.map +1 -1
  52. package/dist/lib/utils.js +9 -0
  53. package/dist/lib/utils.js.map +1 -1
  54. package/dist/sdk/edge.d.ts +65 -0
  55. package/dist/sdk/edge.d.ts.map +1 -0
  56. package/dist/sdk/edge.js +359 -0
  57. package/dist/sdk/edge.js.map +1 -0
  58. package/dist/sdk/errors.d.ts +64 -0
  59. package/dist/sdk/errors.d.ts.map +1 -0
  60. package/dist/sdk/errors.js +94 -0
  61. package/dist/sdk/errors.js.map +1 -0
  62. package/dist/sdk/index.d.ts +29 -0
  63. package/dist/sdk/index.d.ts.map +1 -0
  64. package/dist/sdk/index.js +30 -0
  65. package/dist/sdk/index.js.map +1 -0
  66. package/dist/sdk/render.d.ts +52 -0
  67. package/dist/sdk/render.d.ts.map +1 -0
  68. package/dist/sdk/render.js +432 -0
  69. package/dist/sdk/render.js.map +1 -0
  70. package/dist/sdk/types.d.ts +185 -0
  71. package/dist/sdk/types.d.ts.map +1 -0
  72. package/dist/sdk/types.js +5 -0
  73. package/dist/sdk/types.js.map +1 -0
  74. package/dist/types/template.d.ts +18 -0
  75. package/dist/types/template.d.ts.map +1 -1
  76. package/package.json +27 -4
  77. package/plans/PLATFORM.md +1637 -237
  78. package/plans/PLATFORM_IMPLEMENTATION.md +1347 -530
  79. package/plans/SDK.md +797 -0
  80. package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite +0 -0
  81. package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite-shm +0 -0
  82. package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite-wal +0 -0
  83. package/platform/migrations/0001_initial.sql +90 -0
  84. package/platform/package-lock.json +3253 -0
  85. package/platform/package.json +30 -0
  86. package/platform/wrangler.toml +43 -0
  87. package/tests-sdk/createRenderer.test.ts +251 -0
  88. package/tests-sdk/errors.test.ts +230 -0
  89. package/tests-sdk/render.test.ts +241 -0
  90. package/tests-sdk/tw.test.ts +277 -0
@@ -1,5 +1,7 @@
1
1
  # Loopwind API Service - Implementation Plan
2
2
 
3
+ > **Note**: This implements the architecture described in [PLATFORM.md](./PLATFORM.md).
4
+
3
5
  ## Overview
4
6
 
5
7
  This document outlines the step-by-step implementation plan for the Loopwind API service using a 100% Cloudflare stack with organization-based user management.
@@ -14,9 +16,9 @@ This document outlines the step-by-step implementation plan for the Loopwind API
14
16
  │ loopwind.dev api.loopwind.dev app.loopwind.dev │
15
17
  │ ───────────── ──────────────── ───────────────── │
16
18
  │ Marketing site REST API Dashboard UI │
17
- │ Documentation Authentication User settings │
18
- │ Blog Template CRUD Template management │
19
- Rendering API Render history
19
+ │ Documentation GET /r/{token} User settings │
20
+ │ Blog POST /r/{token} Template management │
21
+ Authentication Analytics
20
22
  │ │
21
23
  │ └── website/ └── platform/ └── website/src/app/ │
22
24
  │ (Astro) (Workers) (Astro) │
@@ -28,12 +30,19 @@ This document outlines the step-by-step implementation plan for the Loopwind API
28
30
  │ Shared Cloudflare Resources │
29
31
  ├─────────────────────────────────────────────────────────────────────────────┤
30
32
  │ │
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
33
+ │ D1 Database KV Namespaces R2 Buckets Analytics
34
+ │ ─────────── ────────────── ────────── Engine
35
+ │ • users • SESSIONS • assets ─────────
36
+ │ • organizations • RATE_LIMIT • renders • renders
37
+ │ • render_tokens RENDER_TOKENS • hits
38
+ │ • custom_domains (cache)
39
+ │ • allowed_hosts │
40
+ │ │
41
+ │ Org DOs (SQLite) │
42
+ │ ───────────────── │
43
+ │ • templates │
44
+ │ • template_files │
45
+ │ • render_jobs │
37
46
  │ │
38
47
  └─────────────────────────────────────────────────────────────────────────────┘
39
48
  ```
@@ -45,10 +54,13 @@ This document outlines the step-by-step implementation plan for the Loopwind API
45
54
  | Marketing & Docs | Astro (website/) |
46
55
  | Dashboard UI | Astro (website/src/pages/app/) |
47
56
  | API | Cloudflare Workers (platform/) |
48
- | Database | Cloudflare D1 (SQLite) |
49
- | Auth | Custom (bcrypt + JWT) |
57
+ | Global Database | Cloudflare D1 (users, organizations, tokens) |
58
+ | Org Data | Durable Objects w/ SQLite (templates, files) |
59
+ | Auth | GitHub OAuth + JWT |
50
60
  | Sessions | Workers KV |
51
61
  | File Storage | Cloudflare R2 |
62
+ | Render Cache | Cloudflare R2 |
63
+ | Analytics | Cloudflare Analytics Engine |
52
64
  | Video Rendering | Cloudflare Containers |
53
65
  | Image Rendering | Workers (WASM) |
54
66
  | CDN | Cloudflare (automatic) |
@@ -59,9 +71,19 @@ This document outlines the step-by-step implementation plan for the Loopwind API
59
71
  Organization
60
72
  └── Users (members)
61
73
  └── API Keys
62
- └── Templates (owned by org, created by user)
74
+ └── Templates (owned by organization, created by user)
75
+ └── Render Tokens (random IDs for URLs)
63
76
  ```
64
77
 
78
+ ### Key Concepts from PLATFORM.md
79
+
80
+ - **Render tokens**: Random IDs like `x7k9m2p4` used in URLs (prevents enumeration)
81
+ - **R2 caching**: Rendered images cached by hash of inputs
82
+ - **Analytics Engine**: Track renders vs hits per template
83
+ - **Allowed hosts**: Security for external image URLs
84
+ - **Version pinning**: `?v=1.0.0` to pin to specific template version
85
+ - **Custom domains**: `og.mysite.com` instead of `api.loopwind.dev/r/...`
86
+
65
87
  ---
66
88
 
67
89
  ## Phase 1: Project Setup & Database Schema
@@ -96,16 +118,15 @@ loopwind/
96
118
  │ ├── src/
97
119
  │ │ ├── index.ts # Main worker entry
98
120
  │ │ ├── routes/
99
- │ │ │ ├── auth.ts # Login, register, logout
100
- │ │ │ ├── organizations.ts # Org management
101
- │ │ │ ├── users.ts # User management
121
+ │ │ │ ├── auth.ts # GitHub OAuth login/logout
122
+ │ │ │ ├── organizations.ts # Organization management
102
123
  │ │ │ ├── templates.ts # Template CRUD & publish
103
- │ │ │ ├── render.ts # Image/video rendering
104
- │ │ │ └── keys.ts # API key management
124
+ │ │ │ ├── render.ts # Public render API (GET/POST /r/{token})
125
+ │ │ │ ├── keys.ts # API key management
126
+ │ │ │ └── analytics.ts # Render/hit stats
105
127
  │ │ ├── middleware/
106
- │ │ │ ├── auth.ts # JWT validation
107
- │ │ │ ├── rateLimit.ts # Rate limiting
108
- │ │ │ └── orgAccess.ts # Organization access control
128
+ │ │ │ ├── auth.ts # JWT validation + organization access
129
+ │ │ │ └── rateLimit.ts # Rate limiting per organization
109
130
  │ │ ├── services/
110
131
  │ │ │ ├── auth.ts # Auth logic
111
132
  │ │ │ ├── render.ts # Render orchestration
@@ -204,14 +225,19 @@ binding = "VIDEO_RENDERER"
204
225
  class_name = "VideoRenderer"
205
226
  image = "./container"
206
227
 
207
- # Durable Objects for job orchestration
228
+ # Durable Objects for organization data (templates, versions, files)
208
229
  [[durable_objects.bindings]]
209
- name = "RENDER_JOBS"
210
- class_name = "RenderJobOrchestrator"
230
+ name = "ORG_DO"
231
+ class_name = "OrgDurableObject"
211
232
 
212
233
  [[migrations]]
213
234
  tag = "v1"
214
- new_classes = ["RenderJobOrchestrator"]
235
+ new_classes = ["OrgDurableObject"]
236
+
237
+ # Analytics Engine for render/hit tracking
238
+ [[analytics_engine_datasets]]
239
+ binding = "ANALYTICS"
240
+ dataset = "loopwind_analytics"
215
241
 
216
242
  # Environment variables
217
243
  [vars]
@@ -221,6 +247,8 @@ CORS_ORIGINS = "https://loopwind.dev,https://app.loopwind.dev"
221
247
 
222
248
  # Secrets (set via `wrangler secret put`)
223
249
  # JWT_SECRET
250
+ # GITHUB_CLIENT_ID
251
+ # GITHUB_CLIENT_SECRET
224
252
  ```
225
253
 
226
254
  ### 1.5 CORS Configuration
@@ -298,26 +326,34 @@ CREATE TABLE organizations (
298
326
  -- Users
299
327
  CREATE TABLE users (
300
328
  id TEXT PRIMARY KEY, -- usr_xxxxx
301
- org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
302
329
 
303
- email TEXT UNIQUE NOT NULL,
304
- username TEXT UNIQUE NOT NULL,
305
- password_hash TEXT NOT NULL,
330
+ -- GitHub OAuth
331
+ github_id TEXT UNIQUE,
332
+ github_username TEXT,
306
333
 
334
+ email TEXT UNIQUE NOT NULL,
307
335
  name TEXT,
308
336
  avatar_url TEXT,
309
- role TEXT DEFAULT 'member', -- owner, admin, member
310
337
 
311
- email_verified_at TEXT,
312
338
  last_login_at TEXT,
313
-
314
339
  created_at TEXT DEFAULT (datetime('now')),
315
340
  updated_at TEXT DEFAULT (datetime('now'))
316
341
  );
317
342
 
318
- CREATE INDEX idx_users_org ON users(org_id);
319
343
  CREATE INDEX idx_users_email ON users(email);
320
- CREATE INDEX idx_users_username ON users(username);
344
+ CREATE INDEX idx_users_github ON users(github_id);
345
+
346
+ -- Organization Members (join table)
347
+ CREATE TABLE org_members (
348
+ org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
349
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
350
+ role TEXT DEFAULT 'member', -- owner, admin, member
351
+
352
+ created_at TEXT DEFAULT (datetime('now')),
353
+ PRIMARY KEY (org_id, user_id)
354
+ );
355
+
356
+ CREATE INDEX idx_org_members_user ON org_members(user_id);
321
357
 
322
358
  -- API Keys
323
359
  CREATE TABLE api_keys (
@@ -340,7 +376,7 @@ CREATE TABLE api_keys (
340
376
  CREATE INDEX idx_api_keys_org ON api_keys(org_id);
341
377
  CREATE INDEX idx_api_keys_prefix ON api_keys(key_prefix);
342
378
 
343
- -- Templates
379
+ -- Templates (stored in Organization Durable Objects, this is just for global lookups)
344
380
  CREATE TABLE templates (
345
381
  id TEXT PRIMARY KEY, -- tpl_xxxxx
346
382
  org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
@@ -351,15 +387,11 @@ CREATE TABLE templates (
351
387
  description TEXT,
352
388
 
353
389
  type TEXT NOT NULL, -- image, video
354
- meta TEXT NOT NULL, -- JSON: { size, props, video? }
390
+ current_version TEXT, -- "1.0.0"
355
391
 
356
392
  is_private INTEGER DEFAULT 0,
357
393
  tags TEXT DEFAULT '[]', -- JSON array
358
394
 
359
- -- Stats
360
- downloads INTEGER DEFAULT 0,
361
- renders INTEGER DEFAULT 0,
362
-
363
395
  created_at TEXT DEFAULT (datetime('now')),
364
396
  updated_at TEXT DEFAULT (datetime('now')),
365
397
 
@@ -370,52 +402,50 @@ CREATE INDEX idx_templates_org ON templates(org_id);
370
402
  CREATE INDEX idx_templates_slug ON templates(slug);
371
403
  CREATE INDEX idx_templates_type ON templates(type);
372
404
 
373
- -- Template Versions
374
- CREATE TABLE template_versions (
375
- id TEXT PRIMARY KEY, -- ver_xxxxx
405
+ -- Render Tokens (random IDs for URLs - prevents enumeration)
406
+ CREATE TABLE render_tokens (
407
+ token TEXT PRIMARY KEY, -- x7k9m2p4 (8 char random)
408
+ org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
376
409
  template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE CASCADE,
377
410
 
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')),
411
+ name TEXT, -- Optional friendly name
412
+ allowed_props TEXT, -- JSON: null = all, or ["title", "image"]
382
413
 
383
- UNIQUE(template_id, version)
414
+ revoked_at TEXT, -- Soft delete
415
+ created_at TEXT DEFAULT (datetime('now'))
384
416
  );
385
417
 
386
- CREATE INDEX idx_versions_template ON template_versions(template_id);
418
+ CREATE INDEX idx_render_tokens_org ON render_tokens(org_id);
419
+ CREATE INDEX idx_render_tokens_template ON render_tokens(template_id);
387
420
 
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),
421
+ -- Custom Domains (og.mysite.com api.loopwind.dev/r/...)
422
+ CREATE TABLE custom_domains (
423
+ id TEXT PRIMARY KEY, -- dom_xxxxx
424
+ org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
394
425
 
395
- status TEXT DEFAULT 'queued', -- queued, processing, completed, failed
426
+ domain TEXT UNIQUE NOT NULL, -- og.mysite.com
427
+ verified_at TEXT, -- NULL until DNS verified
396
428
 
397
- props TEXT, -- JSON
398
- format TEXT NOT NULL, -- mp4, gif
429
+ created_at TEXT DEFAULT (datetime('now'))
430
+ );
399
431
 
400
- progress REAL DEFAULT 0, -- 0.0 - 1.0
401
- current_frame INTEGER,
402
- total_frames INTEGER,
432
+ CREATE INDEX idx_custom_domains_org ON custom_domains(org_id);
433
+ CREATE INDEX idx_custom_domains_domain ON custom_domains(domain);
403
434
 
404
- output_key TEXT, -- R2 key for output
405
- error TEXT,
435
+ -- Allowed Hosts (security for external image URLs)
436
+ CREATE TABLE allowed_hosts (
437
+ id TEXT PRIMARY KEY, -- ah_xxxxx
438
+ org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
406
439
 
407
- webhook_url TEXT,
408
- webhook_sent INTEGER DEFAULT 0,
440
+ pattern TEXT NOT NULL, -- *.example.com or cdn.example.com
409
441
 
410
- started_at TEXT,
411
- completed_at TEXT,
412
- created_at TEXT DEFAULT (datetime('now'))
442
+ created_at TEXT DEFAULT (datetime('now')),
443
+ UNIQUE(org_id, pattern)
413
444
  );
414
445
 
415
- CREATE INDEX idx_jobs_org ON render_jobs(org_id);
416
- CREATE INDEX idx_jobs_status ON render_jobs(status);
446
+ CREATE INDEX idx_allowed_hosts_org ON allowed_hosts(org_id);
417
447
 
418
- -- Invitations (for adding users to orgs)
448
+ -- Invitations (for adding users to organizations)
419
449
  CREATE TABLE invitations (
420
450
  id TEXT PRIMARY KEY, -- inv_xxxxx
421
451
  org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
@@ -441,8 +471,8 @@ CREATE TABLE audit_log (
441
471
  org_id TEXT NOT NULL,
442
472
  user_id TEXT,
443
473
 
444
- action TEXT NOT NULL, -- user.login, template.publish, etc.
445
- resource_type TEXT, -- user, template, api_key
474
+ action TEXT NOT NULL, -- user.login, template.publish, render.create
475
+ resource_type TEXT, -- user, template, api_key, render_token
446
476
  resource_id TEXT,
447
477
 
448
478
  metadata TEXT, -- JSON
@@ -509,10 +539,15 @@ export interface Env {
509
539
  VIDEO_RENDERER: Container;
510
540
 
511
541
  // Durable Objects
512
- RENDER_JOBS: DurableObjectNamespace;
542
+ ORG_DO: DurableObjectNamespace;
543
+
544
+ // Analytics Engine
545
+ ANALYTICS: AnalyticsEngineDataset;
513
546
 
514
547
  // Secrets
515
548
  JWT_SECRET: string;
549
+ GITHUB_CLIENT_ID: string;
550
+ GITHUB_CLIENT_SECRET: string;
516
551
 
517
552
  // Vars
518
553
  JWT_ISSUER: string;
@@ -521,11 +556,11 @@ export interface Env {
521
556
 
522
557
  export interface User {
523
558
  id: string;
524
- org_id: string;
559
+ github_id: string;
560
+ github_username: string;
525
561
  email: string;
526
- username: string;
527
562
  name: string | null;
528
- role: 'owner' | 'admin' | 'member';
563
+ avatar_url: string | null;
529
564
  }
530
565
 
531
566
  export interface Organization {
@@ -535,10 +570,22 @@ export interface Organization {
535
570
  plan: 'free' | 'pro' | 'business';
536
571
  }
537
572
 
573
+ export interface OrgMember {
574
+ org_id: string;
575
+ user_id: string;
576
+ role: 'owner' | 'admin' | 'member';
577
+ }
578
+
579
+ export interface RenderToken {
580
+ token: string;
581
+ org_id: string;
582
+ template_id: string;
583
+ name: string | null;
584
+ allowed_props: string[] | null;
585
+ }
586
+
538
587
  export interface JWTPayload {
539
588
  sub: string; // user_id
540
- org: string; // org_id
541
- role: string; // user role
542
589
  iss: string;
543
590
  aud: string;
544
591
  iat: number;
@@ -649,6 +696,8 @@ export const generateId = {
649
696
  version: () => `ver_${nanoid(16)}`,
650
697
  job: () => `job_${nanoid(16)}`,
651
698
  invitation: () => `inv_${nanoid(16)}`,
699
+ domain: () => `dom_${nanoid(16)}`,
700
+ allowedHost: () => `ah_${nanoid(16)}`,
652
701
  };
653
702
 
654
703
  export function generateApiKey(): { key: string; prefix: string } {
@@ -657,173 +706,165 @@ export function generateApiKey(): { key: string; prefix: string } {
657
706
  return { key, prefix };
658
707
  }
659
708
 
709
+ export function generateRenderToken(): string {
710
+ // Short random token for URLs: x7k9m2p4
711
+ return nanoid(8);
712
+ }
713
+
660
714
  export function generateInviteToken(): string {
661
715
  return nanoid(32);
662
716
  }
663
717
  ```
664
718
 
665
- ### 2.5 Auth Routes
719
+ ### 2.5 Auth Routes (GitHub OAuth)
666
720
 
667
721
  ```typescript
668
722
  // src/routes/auth.ts
669
723
  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';
724
+ import type { Env } from '../types/env';
673
725
  import { signAccessToken, signRefreshToken, verifyToken } from '../lib/jwt';
674
726
  import { generateId } from '../lib/id';
675
727
 
676
728
  const auth = new Hono<{ Bindings: Env }>();
677
729
 
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
- });
730
+ // GET /auth/github - Redirect to GitHub OAuth
731
+ auth.get('/github', async (c) => {
732
+ const state = crypto.randomUUID();
686
733
 
687
- const loginSchema = z.object({
688
- email: z.string().email(),
689
- password: z.string(),
690
- });
734
+ // Store state in KV for CSRF protection
735
+ await c.env.SESSIONS.put(`oauth_state:${state}`, '1', { expirationTtl: 600 });
691
736
 
692
- // POST /auth/register
693
- auth.post('/register', async (c) => {
694
- const body = await c.req.json();
695
- const parsed = registerSchema.safeParse(body);
737
+ const params = new URLSearchParams({
738
+ client_id: c.env.GITHUB_CLIENT_ID,
739
+ redirect_uri: 'https://api.loopwind.dev/auth/github/callback',
740
+ scope: 'read:user user:email',
741
+ state,
742
+ });
696
743
 
697
- if (!parsed.success) {
698
- return c.json({ error: 'Validation failed', details: parsed.error.flatten() }, 400);
699
- }
744
+ return c.redirect(`https://github.com/login/oauth/authorize?${params}`);
745
+ });
700
746
 
701
- const { email, username, password, name, orgName } = parsed.data;
747
+ // GET /auth/github/callback - Handle OAuth callback
748
+ auth.get('/github/callback', async (c) => {
749
+ const code = c.req.query('code');
750
+ const state = c.req.query('state');
702
751
 
703
- // Validate password strength
704
- const pwCheck = validatePasswordStrength(password);
705
- if (!pwCheck.valid) {
706
- return c.json({ error: 'Weak password', details: pwCheck.errors }, 400);
752
+ if (!code || !state) {
753
+ return c.redirect('https://app.loopwind.dev/login?error=missing_params');
707
754
  }
708
755
 
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);
756
+ // Verify state
757
+ const storedState = await c.env.SESSIONS.get(`oauth_state:${state}`);
758
+ if (!storedState) {
759
+ return c.redirect('https://app.loopwind.dev/login?error=invalid_state');
716
760
  }
761
+ await c.env.SESSIONS.delete(`oauth_state:${state}`);
717
762
 
718
- // Create organization
719
- const orgId = generateId.org();
720
- const orgSlug = orgName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
763
+ // Exchange code for access token
764
+ const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
765
+ method: 'POST',
766
+ headers: {
767
+ 'Accept': 'application/json',
768
+ 'Content-Type': 'application/json',
769
+ },
770
+ body: JSON.stringify({
771
+ client_id: c.env.GITHUB_CLIENT_ID,
772
+ client_secret: c.env.GITHUB_CLIENT_SECRET,
773
+ code,
774
+ }),
775
+ });
721
776
 
722
- // Check org slug uniqueness
723
- const existingOrg = await c.env.DB.prepare(
724
- 'SELECT id FROM organizations WHERE slug = ?'
725
- ).bind(orgSlug).first();
777
+ const tokenData = await tokenResponse.json<{ access_token?: string; error?: string }>();
726
778
 
727
- if (existingOrg) {
728
- return c.json({ error: 'Organization name already taken' }, 409);
779
+ if (tokenData.error || !tokenData.access_token) {
780
+ return c.redirect('https://app.loopwind.dev/login?error=oauth_failed');
729
781
  }
730
782
 
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),
783
+ // Fetch user info from GitHub
784
+ const [userResponse, emailsResponse] = await Promise.all([
785
+ fetch('https://api.github.com/user', {
786
+ headers: { Authorization: `Bearer ${tokenData.access_token}` },
787
+ }),
788
+ fetch('https://api.github.com/user/emails', {
789
+ headers: { Authorization: `Bearer ${tokenData.access_token}` },
790
+ }),
748
791
  ]);
749
792
 
750
- // Generate tokens
751
- const accessToken = await signAccessToken({ sub: userId, org: orgId, role: 'owner' }, c.env);
752
- const refreshToken = await signRefreshToken(userId, c.env);
793
+ const githubUser = await userResponse.json<{
794
+ id: number;
795
+ login: string;
796
+ name: string | null;
797
+ avatar_url: string;
798
+ }>();
753
799
 
754
- // Store refresh token in KV
755
- await c.env.SESSIONS.put(`refresh:${userId}`, refreshToken, { expirationTtl: 7 * 24 * 60 * 60 });
800
+ const emails = await emailsResponse.json<Array<{
801
+ email: string;
802
+ primary: boolean;
803
+ verified: boolean;
804
+ }>>();
756
805
 
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
- });
806
+ const primaryEmail = emails.find(e => e.primary && e.verified)?.email;
807
+ if (!primaryEmail) {
808
+ return c.redirect('https://app.loopwind.dev/login?error=no_verified_email');
809
+ }
764
810
 
765
- // POST /auth/login
766
- auth.post('/login', async (c) => {
767
- const body = await c.req.json();
768
- const parsed = loginSchema.safeParse(body);
811
+ // Find or create user
812
+ let user = await c.env.DB.prepare(`
813
+ SELECT id, github_id, email, name, avatar_url FROM users WHERE github_id = ?
814
+ `).bind(String(githubUser.id)).first<any>();
769
815
 
770
- if (!parsed.success) {
771
- return c.json({ error: 'Validation failed' }, 400);
772
- }
816
+ if (!user) {
817
+ // Create new user
818
+ const userId = generateId.user();
773
819
 
774
- const { email, password } = parsed.data;
820
+ await c.env.DB.prepare(`
821
+ INSERT INTO users (id, github_id, github_username, email, name, avatar_url)
822
+ VALUES (?, ?, ?, ?, ?, ?)
823
+ `).bind(
824
+ userId,
825
+ String(githubUser.id),
826
+ githubUser.login,
827
+ primaryEmail,
828
+ githubUser.name,
829
+ githubUser.avatar_url
830
+ ).run();
831
+
832
+ // Create default personal organization
833
+ const orgId = generateId.org();
834
+ const orgSlug = githubUser.login.toLowerCase();
775
835
 
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>();
836
+ await c.env.DB.batch([
837
+ c.env.DB.prepare(`
838
+ INSERT INTO organizations (id, name, slug, usage_reset_at)
839
+ VALUES (?, ?, ?, ?)
840
+ `).bind(orgId, `${githubUser.login}'s Organization`, orgSlug, getNextMonthReset()),
784
841
 
785
- if (!user) {
786
- return c.json({ error: 'Invalid credentials' }, 401);
787
- }
842
+ c.env.DB.prepare(`
843
+ INSERT INTO org_members (org_id, user_id, role)
844
+ VALUES (?, ?, 'owner')
845
+ `).bind(orgId, userId),
846
+ ]);
788
847
 
789
- // Verify password
790
- const valid = await verifyPassword(password, user.password_hash);
791
- if (!valid) {
792
- return c.json({ error: 'Invalid credentials' }, 401);
848
+ user = { id: userId, github_id: String(githubUser.id), email: primaryEmail, name: githubUser.name, avatar_url: githubUser.avatar_url };
849
+ } else {
850
+ // Update user info
851
+ await c.env.DB.prepare(`
852
+ UPDATE users SET github_username = ?, name = ?, avatar_url = ?, last_login_at = ?
853
+ WHERE id = ?
854
+ `).bind(githubUser.login, githubUser.name, githubUser.avatar_url, new Date().toISOString(), user.id).run();
793
855
  }
794
856
 
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
857
  // Generate tokens
801
- const accessToken = await signAccessToken(
802
- { sub: user.id, org: user.org_id, role: user.role },
803
- c.env
804
- );
858
+ const accessToken = await signAccessToken({ sub: user.id }, c.env);
805
859
  const refreshToken = await signRefreshToken(user.id, c.env);
806
860
 
807
- // Store refresh token
861
+ // Store refresh token in KV
808
862
  await c.env.SESSIONS.put(`refresh:${user.id}`, refreshToken, { expirationTtl: 7 * 24 * 60 * 60 });
809
863
 
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
- });
864
+ // Redirect to app with tokens (using fragment to keep them out of server logs)
865
+ return c.redirect(
866
+ `https://app.loopwind.dev/auth/callback#access_token=${accessToken}&refresh_token=${refreshToken}`
867
+ );
827
868
  });
828
869
 
829
870
  // POST /auth/refresh
@@ -836,7 +877,7 @@ auth.post('/refresh', async (c) => {
836
877
 
837
878
  // Verify refresh token
838
879
  const payload = await verifyToken(refreshToken, c.env);
839
- if (!payload || payload.type !== 'refresh') {
880
+ if (!payload || (payload as any).type !== 'refresh') {
840
881
  return c.json({ error: 'Invalid refresh token' }, 401);
841
882
  }
842
883
 
@@ -848,7 +889,7 @@ auth.post('/refresh', async (c) => {
848
889
 
849
890
  // Get user
850
891
  const user = await c.env.DB.prepare(`
851
- SELECT id, org_id, role FROM users WHERE id = ?
892
+ SELECT id FROM users WHERE id = ?
852
893
  `).bind(payload.sub).first<any>();
853
894
 
854
895
  if (!user) {
@@ -856,10 +897,7 @@ auth.post('/refresh', async (c) => {
856
897
  }
857
898
 
858
899
  // Generate new tokens
859
- const newAccessToken = await signAccessToken(
860
- { sub: user.id, org: user.org_id, role: user.role },
861
- c.env
862
- );
900
+ const newAccessToken = await signAccessToken({ sub: user.id }, c.env);
863
901
  const newRefreshToken = await signRefreshToken(user.id, c.env);
864
902
 
865
903
  // Update refresh token in KV
@@ -871,6 +909,38 @@ auth.post('/refresh', async (c) => {
871
909
  });
872
910
  });
873
911
 
912
+ // GET /auth/me - Get current user with organizations
913
+ auth.get('/me', async (c) => {
914
+ const authHeader = c.req.header('Authorization');
915
+ if (!authHeader?.startsWith('Bearer ')) {
916
+ return c.json({ error: 'Unauthorized' }, 401);
917
+ }
918
+
919
+ const token = authHeader.slice(7);
920
+ const payload = await verifyToken(token, c.env);
921
+ if (!payload) {
922
+ return c.json({ error: 'Invalid token' }, 401);
923
+ }
924
+
925
+ const user = await c.env.DB.prepare(`
926
+ SELECT id, email, name, avatar_url, github_username FROM users WHERE id = ?
927
+ `).bind(payload.sub).first<any>();
928
+
929
+ if (!user) {
930
+ return c.json({ error: 'User not found' }, 404);
931
+ }
932
+
933
+ // Get user's organizations
934
+ const { results: organizations } = await c.env.DB.prepare(`
935
+ SELECT t.id, t.name, t.slug, t.plan, tm.role
936
+ FROM organizations o
937
+ JOIN org_members tm ON t.id = tm.org_id
938
+ WHERE tm.user_id = ?
939
+ `).bind(user.id).all<any>();
940
+
941
+ return c.json({ user, organizations });
942
+ });
943
+
874
944
  // POST /auth/logout
875
945
  auth.post('/logout', async (c) => {
876
946
  const authHeader = c.req.header('Authorization');
@@ -905,13 +975,14 @@ export default auth;
905
975
  // src/middleware/auth.ts
906
976
  import { Context, Next } from 'hono';
907
977
  import { verifyToken } from '../lib/jwt';
908
- import type { Env, User, JWTPayload } from '../types/env';
978
+ import type { Env, JWTPayload } from '../types/env';
909
979
 
910
980
  // Extend Hono context
911
981
  declare module 'hono' {
912
982
  interface ContextVariableMap {
913
983
  user: JWTPayload;
914
- apiKey?: { id: string; scopes: string[] };
984
+ orgId?: string; // Set when accessing organization-specific routes
985
+ apiKey?: { id: string; scopes: string[]; org_id: string };
915
986
  }
916
987
  }
917
988
 
@@ -942,9 +1013,8 @@ export async function authMiddleware(c: Context<{ Bindings: Env }>, next: Next)
942
1013
 
943
1014
  // Find API key by prefix
944
1015
  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
1016
+ SELECT ak.id, ak.key_hash, ak.scopes, ak.org_id, ak.user_id
946
1017
  FROM api_keys ak
947
- JOIN users u ON ak.user_id = u.id
948
1018
  WHERE ak.key_prefix = ?
949
1019
  AND (ak.expires_at IS NULL OR ak.expires_at > datetime('now'))
950
1020
  `).bind(keyPrefix).first<any>();
@@ -967,12 +1037,9 @@ export async function authMiddleware(c: Context<{ Bindings: Env }>, next: Next)
967
1037
  ).bind(new Date().toISOString(), key.id).run();
968
1038
 
969
1039
  // 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) });
1040
+ c.set('user', { sub: key.user_id } as JWTPayload);
1041
+ c.set('orgId', key.org_id);
1042
+ c.set('apiKey', { id: key.id, scopes: JSON.parse(key.scopes), org_id: key.org_id });
976
1043
 
977
1044
  return next();
978
1045
  }
@@ -1000,15 +1067,37 @@ export function requireScopes(...scopes: string[]) {
1000
1067
  };
1001
1068
  }
1002
1069
 
1003
- // Role checking middleware
1004
- export function requireRole(...roles: string[]) {
1070
+ // Organization access middleware - validates user has access to the organization
1071
+ export function requireOrgAccess(roleRequired?: 'owner' | 'admin' | 'member') {
1005
1072
  return async (c: Context<{ Bindings: Env }>, next: Next) => {
1006
1073
  const user = c.get('user');
1074
+ const orgId = c.req.param('orgId') || c.get('orgId');
1007
1075
 
1008
- if (!roles.includes(user.role)) {
1009
- return c.json({ error: 'Insufficient role', required: roles }, 403);
1076
+ if (!orgId) {
1077
+ return c.json({ error: 'Organization ID required' }, 400);
1010
1078
  }
1011
1079
 
1080
+ // Check organization membership
1081
+ const member = await c.env.DB.prepare(`
1082
+ SELECT role FROM org_members WHERE org_id = ? AND user_id = ?
1083
+ `).bind(orgId, user.sub).first<{ role: string }>();
1084
+
1085
+ if (!member) {
1086
+ return c.json({ error: 'Not a member of this organization' }, 403);
1087
+ }
1088
+
1089
+ // Check role if required
1090
+ if (roleRequired) {
1091
+ const roleHierarchy = ['member', 'admin', 'owner'];
1092
+ const userLevel = roleHierarchy.indexOf(member.role);
1093
+ const requiredLevel = roleHierarchy.indexOf(roleRequired);
1094
+
1095
+ if (userLevel < requiredLevel) {
1096
+ return c.json({ error: 'Insufficient permissions', required: roleRequired }, 403);
1097
+ }
1098
+ }
1099
+
1100
+ c.set('orgId', orgId);
1012
1101
  return next();
1013
1102
  };
1014
1103
  }
@@ -1033,16 +1122,16 @@ const PLAN_LIMITS: Record<string, RateLimitConfig> = {
1033
1122
  };
1034
1123
 
1035
1124
  export async function rateLimitMiddleware(c: Context<{ Bindings: Env }>, next: Next) {
1036
- const user = c.get('user');
1037
- if (!user) return next();
1125
+ const orgId = c.get('orgId');
1126
+ if (!orgId) return next();
1038
1127
 
1039
- // Get org plan
1128
+ // Get organization plan
1040
1129
  const org = await c.env.DB.prepare(
1041
1130
  'SELECT plan FROM organizations WHERE id = ?'
1042
- ).bind(user.org).first<{ plan: string }>();
1131
+ ).bind(orgId).first<{ plan: string }>();
1043
1132
 
1044
1133
  const limits = PLAN_LIMITS[org?.plan || 'free'];
1045
- const key = `rate:${user.org}:${Math.floor(Date.now() / limits.windowMs)}`;
1134
+ const key = `rate:${orgId}:${Math.floor(Date.now() / limits.windowMs)}`;
1046
1135
 
1047
1136
  // Get current count
1048
1137
  const current = await c.env.RATE_LIMIT.get(key);
@@ -1079,8 +1168,8 @@ export async function rateLimitMiddleware(c: Context<{ Bindings: Env }>, next: N
1079
1168
  import { Hono } from 'hono';
1080
1169
  import { z } from 'zod';
1081
1170
  import type { Env } from '../types/env';
1082
- import { authMiddleware, requireScopes } from '../middleware/auth';
1083
- import { generateId } from '../lib/id';
1171
+ import { authMiddleware, requireScopes, requireOrgAccess } from '../middleware/auth';
1172
+ import { generateId, generateRenderToken } from '../lib/id';
1084
1173
 
1085
1174
  const templates = new Hono<{ Bindings: Env }>();
1086
1175
 
@@ -1105,9 +1194,10 @@ const publishSchema = z.object({
1105
1194
  })),
1106
1195
  });
1107
1196
 
1108
- // POST /templates/publish
1109
- templates.post('/publish', requireScopes('templates:write'), async (c) => {
1197
+ // POST /organizations/:orgId/templates/publish
1198
+ templates.post('/:orgId/publish', requireOrgAccess('member'), requireScopes('templates:write'), async (c) => {
1110
1199
  const user = c.get('user');
1200
+ const orgId = c.get('orgId')!;
1111
1201
  const body = await c.req.json();
1112
1202
 
1113
1203
  const parsed = publishSchema.safeParse(body);
@@ -1117,15 +1207,15 @@ templates.post('/publish', requireScopes('templates:write'), async (c) => {
1117
1207
 
1118
1208
  const { name, version, description, isPrivate, tags, meta, files } = parsed.data;
1119
1209
 
1120
- // Get org for slug
1210
+ // Get organization for slug and limits
1121
1211
  const org = await c.env.DB.prepare(
1122
1212
  'SELECT slug, templates_limit FROM organizations WHERE id = ?'
1123
- ).bind(user.org).first<any>();
1213
+ ).bind(orgId).first<any>();
1124
1214
 
1125
1215
  // Check template limit
1126
1216
  const templateCount = await c.env.DB.prepare(
1127
1217
  'SELECT COUNT(*) as count FROM templates WHERE org_id = ?'
1128
- ).bind(user.org).first<{ count: number }>();
1218
+ ).bind(orgId).first<{ count: number }>();
1129
1219
 
1130
1220
  if (templateCount!.count >= org.templates_limit) {
1131
1221
  return c.json({ error: 'Template limit reached', limit: org.templates_limit }, 403);
@@ -1136,7 +1226,7 @@ templates.post('/publish', requireScopes('templates:write'), async (c) => {
1136
1226
  // Check if template exists
1137
1227
  let template = await c.env.DB.prepare(
1138
1228
  'SELECT id FROM templates WHERE org_id = ? AND name = ?'
1139
- ).bind(user.org, name).first<{ id: string }>();
1229
+ ).bind(orgId, name).first<{ id: string }>();
1140
1230
 
1141
1231
  const templateId = template?.id || generateId.template();
1142
1232
 
@@ -1169,51 +1259,52 @@ templates.post('/publish', requireScopes('templates:write'), async (c) => {
1169
1259
  uploadedFiles.push({ path: file.path, r2Key, checksum: checksumHex });
1170
1260
  }
1171
1261
 
1172
- // Create or update template
1262
+ // Create or update template + create render token
1173
1263
  const versionId = generateId.version();
1264
+ const renderToken = generateRenderToken();
1174
1265
 
1175
1266
  if (!template) {
1176
1267
  await c.env.DB.batch([
1177
1268
  c.env.DB.prepare(`
1178
- INSERT INTO templates (id, org_id, created_by, name, slug, description, type, meta, is_private, tags)
1269
+ INSERT INTO templates (id, org_id, created_by, name, slug, description, type, current_version, is_private, tags)
1179
1270
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1180
1271
  `).bind(
1181
- templateId, user.org, user.sub, name, slug,
1182
- description || null, meta.type, JSON.stringify(meta),
1272
+ templateId, orgId, user.sub, name, slug,
1273
+ description || null, meta.type, version,
1183
1274
  isPrivate ? 1 : 0, JSON.stringify(tags)
1184
1275
  ),
1185
1276
  c.env.DB.prepare(`
1186
- INSERT INTO template_versions (id, template_id, version, files)
1187
- VALUES (?, ?, ?, ?)
1188
- `).bind(versionId, templateId, version, JSON.stringify(uploadedFiles)),
1277
+ INSERT INTO render_tokens (token, org_id, template_id)
1278
+ VALUES (?, ?, ?)
1279
+ `).bind(renderToken, orgId, templateId),
1189
1280
  ]);
1190
1281
  } 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
- ]);
1282
+ await c.env.DB.prepare(`
1283
+ UPDATE templates SET description = ?, current_version = ?, is_private = ?, tags = ?, updated_at = datetime('now')
1284
+ WHERE id = ?
1285
+ `).bind(description || null, version, isPrivate ? 1 : 0, JSON.stringify(tags), templateId).run();
1201
1286
  }
1202
1287
 
1288
+ // Get existing render token if template already existed
1289
+ const existingToken = template
1290
+ ? await c.env.DB.prepare('SELECT token FROM render_tokens WHERE template_id = ? AND revoked_at IS NULL').bind(templateId).first<{token: string}>()
1291
+ : null;
1292
+
1203
1293
  return c.json({
1204
1294
  id: templateId,
1205
1295
  name,
1206
1296
  slug,
1207
1297
  version,
1208
- url: `https://loopwind.dev/${slug}`,
1298
+ renderToken: existingToken?.token || renderToken,
1299
+ renderUrl: `https://api.loopwind.dev/r/${existingToken?.token || renderToken}`,
1209
1300
  publishedAt: new Date().toISOString(),
1210
1301
  }, 201);
1211
1302
  });
1212
1303
 
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();
1304
+ // GET /organizations/:orgId/templates - List organization templates
1305
+ templates.get('/:orgId', requireOrgAccess(), async (c) => {
1306
+ const orgId = c.get('orgId')!;
1307
+ const { q, type, sort = 'recent', page = '1', limit = '20' } = c.req.query();
1217
1308
 
1218
1309
  const offset = (parseInt(page) - 1) * parseInt(limit);
1219
1310
 
@@ -1422,279 +1513,770 @@ export const publishCommand = new Command('publish')
1422
1513
 
1423
1514
  ---
1424
1515
 
1425
- ## Phase 4: Image Rendering API
1516
+ ## Phase 4: Public Render API
1426
1517
 
1427
- ### 4.1 Render Routes
1518
+ > **Key change from PLATFORM.md**: Uses random render tokens in URLs (`/r/x7k9m2p4`) instead of template slugs. This prevents enumeration and allows fine-grained access control.
1519
+
1520
+ ### 4.1 Render Routes (GET/POST /r/{token})
1428
1521
 
1429
1522
  ```typescript
1430
1523
  // src/routes/render.ts
1431
1524
  import { Hono } from 'hono';
1432
- import { z } from 'zod';
1433
1525
  import type { Env } from '../types/env';
1434
- import { authMiddleware, requireScopes } from '../middleware/auth';
1435
1526
  import { generateId } from '../lib/id';
1436
1527
 
1437
1528
  const render = new Hono<{ Bindings: Env }>();
1438
1529
 
1439
- render.use('*', authMiddleware);
1530
+ // GET /r/:token - URL-based rendering (for OG images, etc.)
1531
+ // Usage: <img src="https://api.loopwind.dev/r/x7k9m2p4?title=Hello&image=https://..." />
1532
+ render.get('/:token', async (c) => {
1533
+ const token = c.req.param('token');
1534
+ const query = c.req.query();
1440
1535
 
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),
1536
+ // Parse props from query params
1537
+ const props = parseProps(query);
1538
+ const format = (query.format as 'png' | 'jpg' | 'webp' | 'svg') || 'png';
1539
+ const version = query.v || 'latest';
1540
+
1541
+ return handleRender(c, token, props, format, version);
1447
1542
  });
1448
1543
 
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(),
1544
+ // POST /r/:token - API-based rendering
1545
+ render.post('/:token', async (c) => {
1546
+ const token = c.req.param('token');
1547
+ const body = await c.req.json<{
1548
+ props?: Record<string, any>;
1549
+ format?: 'png' | 'jpg' | 'webp' | 'svg';
1550
+ version?: string;
1551
+ }>();
1552
+
1553
+ const props = body.props || {};
1554
+ const format = body.format || 'png';
1555
+ const version = body.version || 'latest';
1556
+
1557
+ return handleRender(c, token, props, format, version);
1455
1558
  });
1456
1559
 
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();
1560
+ // Main render handler
1561
+ async function handleRender(
1562
+ c: any,
1563
+ token: string,
1564
+ props: Record<string, any>,
1565
+ format: string,
1566
+ version: string
1567
+ ) {
1568
+ const env: Env = c.env;
1569
+
1570
+ // 1. Look up render token (KV cache first, then D1)
1571
+ const tokenData = await lookupRenderToken(token, env);
1572
+ if (!tokenData) {
1573
+ return c.json({ error: 'Invalid render token' }, 404);
1574
+ }
1575
+
1576
+ // 2. Check if props are allowed
1577
+ if (tokenData.allowed_props) {
1578
+ const allowed = JSON.parse(tokenData.allowed_props);
1579
+ const propsKeys = Object.keys(props);
1580
+ const invalidKeys = propsKeys.filter(k => !allowed.includes(k));
1581
+ if (invalidKeys.length > 0) {
1582
+ return c.json({ error: 'Props not allowed', invalid: invalidKeys }, 400);
1583
+ }
1584
+ }
1461
1585
 
1462
- const parsed = imageRenderSchema.safeParse(body);
1463
- if (!parsed.success) {
1464
- return c.json({ error: 'Validation failed', details: parsed.error.flatten() }, 400);
1586
+ // 3. Validate external URLs in props (allowed hosts check)
1587
+ const urlProps = extractUrls(props);
1588
+ if (urlProps.length > 0) {
1589
+ const validationResult = await validateExternalUrls(urlProps, tokenData.org_id, env);
1590
+ if (!validationResult.valid) {
1591
+ return c.json({ error: 'External URL not allowed', blocked: validationResult.blocked }, 403);
1592
+ }
1465
1593
  }
1466
1594
 
1467
- const { template, version, props, format, scale } = parsed.data;
1595
+ // 4. Resolve version
1596
+ const resolvedVersion = version === 'latest'
1597
+ ? await getLatestVersion(tokenData.template_id, env)
1598
+ : version;
1468
1599
 
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>();
1600
+ // 5. Check R2 cache
1601
+ const cacheKey = getCacheKey(tokenData.template_id, resolvedVersion, props, format);
1602
+ const cached = await env.RENDERS.get(cacheKey);
1474
1603
 
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;
1604
+ if (cached) {
1605
+ // Track cache hit
1606
+ env.ANALYTICS.writeDataPoint({
1607
+ indexes: [tokenData.template_id],
1608
+ blobs: [tokenData.org_id, 'hit', format],
1609
+ doubles: [1],
1610
+ });
1611
+
1612
+ return new Response(cached.body, {
1613
+ headers: {
1614
+ 'Content-Type': getContentType(format),
1615
+ 'Cache-Control': 'public, max-age=31536000, immutable',
1616
+ 'X-Cache': 'HIT',
1617
+ },
1618
+ });
1483
1619
  }
1484
1620
 
1621
+ // 6. Check usage limits
1622
+ const org = await env.DB.prepare(`
1623
+ SELECT image_renders_limit, image_renders_used, usage_reset_at
1624
+ FROM organizations WHERE id = ?
1625
+ `).bind(tokenData.org_id).first<any>();
1626
+
1485
1627
  if (org.image_renders_used >= org.image_renders_limit) {
1486
- return c.json({ error: 'Image render limit exceeded', limit: org.image_renders_limit }, 403);
1628
+ return c.json({ error: 'Render limit exceeded' }, 429);
1487
1629
  }
1488
1630
 
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
- }
1631
+ // 7. Get template from Organization Durable Object
1632
+ const orgDO = env.ORG_DO.get(env.ORG_DO.idFromName(tokenData.org_id));
1633
+ const templateResponse = await orgDO.fetch(`http://internal/template/${tokenData.template_id}/${resolvedVersion}`);
1494
1634
 
1495
- if (tpl.type !== 'image') {
1496
- return c.json({ error: 'Use /render/async for video templates' }, 400);
1635
+ if (!templateResponse.ok) {
1636
+ return c.json({ error: 'Template not found' }, 404);
1497
1637
  }
1498
1638
 
1499
- // Load template files from R2
1500
- const templateCode = await loadTemplateFromR2(c.env, tpl);
1639
+ const templateData = await templateResponse.json<{
1640
+ code: string;
1641
+ meta: any;
1642
+ assets: Record<string, string>;
1643
+ }>();
1501
1644
 
1502
- // Render image (using loopwind core renderer)
1645
+ // 8. Render image
1503
1646
  const startTime = Date.now();
1504
- const imageBuffer = await renderImage(templateCode, props, format, scale);
1647
+ const imageBuffer = await renderImage(templateData.code, props, format, templateData.meta);
1505
1648
  const duration = Date.now() - startTime;
1506
1649
 
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();
1650
+ // 9. Store in R2 cache
1651
+ await env.RENDERS.put(cacheKey, imageBuffer, {
1652
+ customMetadata: {
1653
+ template_id: tokenData.template_id,
1654
+ version: resolvedVersion,
1655
+ format,
1656
+ created_at: new Date().toISOString(),
1657
+ },
1658
+ });
1511
1659
 
1512
- // Increment template renders
1513
- await c.env.DB.prepare(
1514
- 'UPDATE templates SET renders = renders + 1 WHERE id = ?'
1515
- ).bind(tpl.id).run();
1660
+ // 10. Track render event
1661
+ env.ANALYTICS.writeDataPoint({
1662
+ indexes: [tokenData.template_id],
1663
+ blobs: [tokenData.org_id, 'render', format],
1664
+ doubles: [1, duration],
1665
+ });
1516
1666
 
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];
1667
+ // 11. Increment usage (non-blocking)
1668
+ c.executionCtx.waitUntil(
1669
+ env.DB.prepare(
1670
+ 'UPDATE organizations SET image_renders_used = image_renders_used + 1 WHERE id = ?'
1671
+ ).bind(tokenData.org_id).run()
1672
+ );
1524
1673
 
1525
1674
  return new Response(imageBuffer, {
1526
1675
  headers: {
1527
- 'Content-Type': contentType,
1676
+ 'Content-Type': getContentType(format),
1677
+ 'Cache-Control': 'public, max-age=31536000, immutable',
1678
+ 'X-Cache': 'MISS',
1528
1679
  'X-Render-Duration': `${duration}ms`,
1529
- 'X-Render-Id': generateId.job(),
1530
1680
  },
1531
1681
  });
1532
- });
1682
+ }
1533
1683
 
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();
1684
+ // KV-cached render token lookup
1685
+ async function lookupRenderToken(token: string, env: Env) {
1686
+ // Check KV cache first (1ms vs 5-10ms D1)
1687
+ const cached = await env.CACHE.get(`rt:${token}`, 'json');
1688
+ if (cached) return cached;
1538
1689
 
1539
- const parsed = videoRenderSchema.safeParse(body);
1540
- if (!parsed.success) {
1541
- return c.json({ error: 'Validation failed', details: parsed.error.flatten() }, 400);
1690
+ // Fall back to D1
1691
+ const row = await env.DB.prepare(`
1692
+ SELECT org_id, template_id, allowed_props
1693
+ FROM render_tokens
1694
+ WHERE token = ? AND revoked_at IS NULL
1695
+ `).bind(token).first<any>();
1696
+
1697
+ if (!row) return null;
1698
+
1699
+ // Cache in KV for 1 hour
1700
+ await env.CACHE.put(`rt:${token}`, JSON.stringify(row), { expirationTtl: 3600 });
1701
+ return row;
1702
+ }
1703
+
1704
+ // Generate deterministic cache key
1705
+ function getCacheKey(
1706
+ templateId: string,
1707
+ version: string,
1708
+ props: object,
1709
+ format: string
1710
+ ): string {
1711
+ const input = JSON.stringify({ templateId, version, props, format });
1712
+ const hash = crypto.subtle.digestSync('SHA-256', new TextEncoder().encode(input));
1713
+ return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 32);
1714
+ }
1715
+
1716
+ // Validate external URLs against allowed hosts
1717
+ async function validateExternalUrls(
1718
+ urls: string[],
1719
+ orgId: string,
1720
+ env: Env
1721
+ ): Promise<{ valid: boolean; blocked: string[] }> {
1722
+ const { results } = await env.DB.prepare(
1723
+ 'SELECT pattern FROM allowed_hosts WHERE org_id = ?'
1724
+ ).bind(orgId).all<{ pattern: string }>();
1725
+
1726
+ const patterns = results.map(r => r.pattern);
1727
+ const blocked: string[] = [];
1728
+
1729
+ for (const url of urls) {
1730
+ const urlObj = new URL(url);
1731
+ const host = urlObj.hostname;
1732
+
1733
+ // Check against patterns
1734
+ const allowed = patterns.some(pattern => {
1735
+ if (pattern.startsWith('*.')) {
1736
+ const suffix = pattern.slice(2);
1737
+ return host.endsWith(suffix) || host === suffix;
1738
+ }
1739
+ return host === pattern;
1740
+ });
1741
+
1742
+ if (!allowed) {
1743
+ blocked.push(url);
1744
+ }
1542
1745
  }
1543
1746
 
1544
- const { template, version, props, format, webhook } = parsed.data;
1747
+ return { valid: blocked.length === 0, blocked };
1748
+ }
1545
1749
 
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>();
1750
+ // Extract URLs from props
1751
+ function extractUrls(props: Record<string, any>): string[] {
1752
+ const urls: string[] = [];
1753
+ const urlRegex = /^https?:\/\//;
1754
+
1755
+ function traverse(obj: any) {
1756
+ if (typeof obj === 'string' && urlRegex.test(obj)) {
1757
+ urls.push(obj);
1758
+ } else if (Array.isArray(obj)) {
1759
+ obj.forEach(traverse);
1760
+ } else if (typeof obj === 'object' && obj !== null) {
1761
+ Object.values(obj).forEach(traverse);
1762
+ }
1763
+ }
1764
+
1765
+ traverse(props);
1766
+ return urls;
1767
+ }
1551
1768
 
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);
1769
+ // Parse props from query string
1770
+ function parseProps(query: Record<string, string>): Record<string, any> {
1771
+ const reserved = ['format', 'v', 'width', 'height'];
1772
+ const props: Record<string, any> = {};
1773
+
1774
+ for (const [key, value] of Object.entries(query)) {
1775
+ if (!reserved.includes(key)) {
1776
+ // Try to parse JSON values
1777
+ try {
1778
+ props[key] = JSON.parse(value);
1779
+ } catch {
1780
+ props[key] = value;
1781
+ }
1782
+ }
1554
1783
  }
1555
1784
 
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);
1785
+ return props;
1786
+ }
1787
+
1788
+ // Get latest version for a template
1789
+ async function getLatestVersion(templateId: string, env: Env): Promise<string> {
1790
+ const row = await env.DB.prepare(
1791
+ 'SELECT current_version FROM templates WHERE id = ?'
1792
+ ).bind(templateId).first<{ current_version: string }>();
1793
+
1794
+ return row?.current_version || '1.0.0';
1795
+ }
1796
+
1797
+ // Content type mapping
1798
+ function getContentType(format: string): string {
1799
+ const types: Record<string, string> = {
1800
+ png: 'image/png',
1801
+ jpg: 'image/jpeg',
1802
+ webp: 'image/webp',
1803
+ svg: 'image/svg+xml',
1804
+ };
1805
+ return types[format] || 'image/png';
1806
+ }
1807
+
1808
+ // Placeholder for actual render function (uses loopwind SDK)
1809
+ async function renderImage(
1810
+ code: string,
1811
+ props: Record<string, any>,
1812
+ format: string,
1813
+ meta: any
1814
+ ): Promise<ArrayBuffer> {
1815
+ // This would use the loopwind SDK to render the template
1816
+ // Implementation depends on how templates are compiled/executed
1817
+ throw new Error('renderImage implementation required');
1818
+ }
1819
+
1820
+ export default render;
1821
+ ```
1822
+
1823
+ ### 4.2 Image Transformations (Cloudflare Images)
1824
+
1825
+ External image URLs passed as props are automatically transformed using [Cloudflare Image Transformations](https://developers.cloudflare.com/images/transform-images/).
1826
+
1827
+ ```typescript
1828
+ // src/lib/image-transform.ts
1829
+
1830
+ interface ImageTransform {
1831
+ width?: number;
1832
+ height?: number;
1833
+ fit?: 'cover' | 'contain' | 'scale-down' | 'crop' | 'pad';
1834
+ format?: 'auto' | 'webp' | 'avif' | 'jpeg' | 'png';
1835
+ quality?: number;
1836
+ }
1837
+
1838
+ /**
1839
+ * Transform an image URL using Cloudflare Images
1840
+ * Uses the /cdn-cgi/image/ endpoint built into all Cloudflare zones
1841
+ */
1842
+ export function transformImageUrl(
1843
+ originalUrl: string,
1844
+ transform: ImageTransform,
1845
+ apiDomain: string = 'api.loopwind.dev'
1846
+ ): string {
1847
+ // Build transform options string
1848
+ const options: string[] = [];
1849
+
1850
+ if (transform.width) options.push(`width=${transform.width}`);
1851
+ if (transform.height) options.push(`height=${transform.height}`);
1852
+ if (transform.fit) options.push(`fit=${transform.fit}`);
1853
+ if (transform.format) options.push(`format=${transform.format}`);
1854
+ if (transform.quality) options.push(`quality=${transform.quality}`);
1855
+
1856
+ // Default to auto format if not specified (serves WebP/AVIF to supporting browsers)
1857
+ if (!transform.format) options.push('format=auto');
1858
+
1859
+ const optionsString = options.join(',');
1860
+
1861
+ // Cloudflare Images URL format: /cdn-cgi/image/{options}/{source-url}
1862
+ return `https://${apiDomain}/cdn-cgi/image/${optionsString}/${originalUrl}`;
1863
+ }
1864
+
1865
+ /**
1866
+ * Apply transforms to all image props based on template meta
1867
+ */
1868
+ export async function transformImageProps(
1869
+ props: Record<string, any>,
1870
+ templateMeta: { props?: Record<string, { type?: string; transform?: ImageTransform }> },
1871
+ env: Env
1872
+ ): Promise<Record<string, any>> {
1873
+ const transformedProps = { ...props };
1874
+
1875
+ if (!templateMeta.props) return transformedProps;
1876
+
1877
+ for (const [key, propDef] of Object.entries(templateMeta.props)) {
1878
+ if (propDef.type !== 'image' || !propDef.transform) continue;
1879
+
1880
+ const propValue = props[key];
1881
+ if (!propValue || typeof propValue !== 'string') continue;
1882
+
1883
+ // Skip if not a URL
1884
+ if (!propValue.startsWith('http://') && !propValue.startsWith('https://')) continue;
1885
+
1886
+ // Apply transform
1887
+ transformedProps[key] = transformImageUrl(propValue, propDef.transform);
1560
1888
  }
1561
1889
 
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;
1890
+ return transformedProps;
1891
+ }
1892
+ ```
1566
1893
 
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();
1894
+ **Integration in render handler:**
1571
1895
 
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
- });
1896
+ ```typescript
1897
+ // In handleRender function (render.ts)
1585
1898
 
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();
1899
+ async function handleRender(c: any, token: string, props: Record<string, any>, ...) {
1900
+ // ... lookup render token, validate hosts ...
1590
1901
 
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);
1902
+ // Get template metadata from Organization DO
1903
+ const templateMeta = await getTemplateMeta(tokenData.template_id, env);
1904
+
1905
+ // Transform image props using Cloudflare Images
1906
+ const transformedProps = await transformImageProps(props, templateMeta, env);
1907
+
1908
+ // Continue with rendering using transformed props
1909
+ const imageBuffer = await renderImage(templateData.code, transformedProps, format, templateMeta);
1910
+
1911
+ // ...
1912
+ }
1913
+ ```
1914
+
1915
+ **Direct image proxy endpoint:**
1916
+
1917
+ ```typescript
1918
+ // src/routes/img.ts
1919
+ import { Hono } from 'hono';
1920
+ import type { Env } from '../types/env';
1921
+
1922
+ const img = new Hono<{ Bindings: Env }>();
1923
+
1924
+ // GET /img?src=<url>&w=<width>&h=<height>&fit=<fit>&f=<format>&q=<quality>
1925
+ // Proxies to Cloudflare Images for transformation
1926
+ img.get('/', async (c) => {
1927
+ const src = c.req.query('src');
1928
+ if (!src) {
1929
+ return c.json({ error: 'Missing src parameter' }, 400);
1930
+ }
1931
+
1932
+ // Validate URL is allowed (use same allowed_hosts logic)
1933
+ // For public endpoint, only allow from default allowed hosts
1934
+ const url = new URL(src);
1935
+ const defaultAllowed = [
1936
+ 'images.unsplash.com',
1937
+ 'cdn.sanity.io',
1938
+ 'res.cloudinary.com',
1939
+ ];
1940
+
1941
+ const isAllowed = defaultAllowed.some(host =>
1942
+ url.hostname === host || url.hostname.endsWith('.' + host)
1943
+ );
1944
+
1945
+ if (!isAllowed) {
1946
+ return c.json({ error: 'Host not allowed for public image proxy' }, 403);
1947
+ }
1948
+
1949
+ // Build Cloudflare Images URL
1950
+ const options: string[] = [];
1951
+ const w = c.req.query('w');
1952
+ const h = c.req.query('h');
1953
+ const fit = c.req.query('fit');
1954
+ const f = c.req.query('f');
1955
+ const q = c.req.query('q');
1956
+
1957
+ if (w) options.push(`width=${w}`);
1958
+ if (h) options.push(`height=${h}`);
1959
+ if (fit) options.push(`fit=${fit}`);
1960
+ options.push(`format=${f || 'auto'}`);
1961
+ if (q) options.push(`quality=${q}`);
1962
+
1963
+ // Redirect to Cloudflare Images endpoint
1964
+ // This leverages CF's built-in image optimization without needing Images subscription
1965
+ const cfImageUrl = `/cdn-cgi/image/${options.join(',')}/${src}`;
1966
+
1967
+ return c.redirect(cfImageUrl, 302);
1599
1968
  });
1600
1969
 
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');
1970
+ export default img;
1971
+ ```
1972
+
1973
+ **Wrangler configuration for Cloudflare Images:**
1974
+
1975
+ ```toml
1976
+ # platform/wrangler.toml
1977
+
1978
+ # Enable Image Resizing (included in Pro plan, or pay-as-you-go)
1979
+ # No additional config needed - /cdn-cgi/image/ is automatic on any CF zone
1980
+ ```
1981
+
1982
+ **Cost considerations:**
1983
+
1984
+ | Plan | Image Transforms |
1985
+ |------|------------------|
1986
+ | Free | Not available |
1987
+ | Pro ($20/mo) | 100K transforms/mo included |
1988
+ | Business | 1M transforms/mo included |
1989
+ | Pay-as-you-go | $9 per 100K transforms |
1990
+
1991
+ ### 4.3 Signed URLs (Private Images)
1992
+
1993
+ For private templates, implement HMAC-SHA256 signed URLs with expiration.
1994
+
1995
+ **Database schema addition:**
1996
+
1997
+ ```sql
1998
+ -- Add to migrations/0001_initial.sql
1999
+
2000
+ -- Signing Keys for private image URLs
2001
+ CREATE TABLE signing_keys (
2002
+ id TEXT PRIMARY KEY, -- sk_xxxxx
2003
+ org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
2004
+
2005
+ key_hash TEXT NOT NULL, -- bcrypt hash of the key
2006
+ key_prefix TEXT NOT NULL, -- sk_live_**** (for display)
2007
+
2008
+ created_at TEXT DEFAULT (datetime('now')),
2009
+ revoked_at TEXT -- Soft delete
2010
+ );
2011
+
2012
+ CREATE INDEX idx_signing_keys_org ON signing_keys(org_id);
2013
+ ```
1605
2014
 
1606
- const job = await c.env.DB.prepare(`
1607
- SELECT * FROM render_jobs WHERE id = ? AND org_id = ?
1608
- `).bind(jobId, user.org).first();
2015
+ **Signature verification middleware:**
1609
2016
 
1610
- if (!job) {
1611
- return c.json({ error: 'Job not found' }, 404);
2017
+ ```typescript
2018
+ // src/middleware/signature.ts
2019
+ import { Context, Next } from 'hono';
2020
+ import type { Env } from '../types/env';
2021
+
2022
+ /**
2023
+ * Verify signed URLs for private templates
2024
+ */
2025
+ export async function verifySignature(c: Context<{ Bindings: Env }>, next: Next) {
2026
+ const exp = c.req.query('exp');
2027
+ const sig = c.req.query('sig');
2028
+ const orgId = c.get('orgId');
2029
+
2030
+ // Check if template requires signature
2031
+ const templateMeta = c.get('templateMeta');
2032
+ if (!templateMeta?.requireSignature) {
2033
+ return next();
1612
2034
  }
1613
2035
 
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
- };
2036
+ // Signature required but not provided
2037
+ if (!exp || !sig) {
2038
+ return c.json({
2039
+ error: 'signature_required',
2040
+ message: 'This template requires a signed URL'
2041
+ }, 403);
2042
+ }
1622
2043
 
1623
- if (job.status === 'processing') {
1624
- response.startedAt = job.started_at;
2044
+ // Check expiration
2045
+ const expTime = parseInt(exp, 10);
2046
+ if (isNaN(expTime) || expTime < Math.floor(Date.now() / 1000)) {
2047
+ return c.json({
2048
+ error: 'signature_expired',
2049
+ message: `URL expired at ${new Date(expTime * 1000).toISOString()}`
2050
+ }, 403);
1625
2051
  }
1626
2052
 
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;
2053
+ // Get signing key for this org
2054
+ const signingKey = await c.env.DB.prepare(`
2055
+ SELECT key_hash FROM signing_keys
2056
+ WHERE org_id = ? AND revoked_at IS NULL
2057
+ ORDER BY created_at DESC LIMIT 1
2058
+ `).bind(orgId).first<{ key_hash: string }>();
2059
+
2060
+ if (!signingKey) {
2061
+ return c.json({
2062
+ error: 'no_signing_key',
2063
+ message: 'No signing key configured for this organization'
2064
+ }, 500);
2065
+ }
2066
+
2067
+ // Reconstruct the data that was signed
2068
+ const url = new URL(c.req.url);
2069
+ url.searchParams.delete('sig'); // Remove sig from verification
2070
+ const dataToSign = url.pathname + url.search;
2071
+
2072
+ // Verify signature
2073
+ const encoder = new TextEncoder();
2074
+ const key = await crypto.subtle.importKey(
2075
+ 'raw',
2076
+ encoder.encode(signingKey.key_hash),
2077
+ { name: 'HMAC', hash: 'SHA-256' },
2078
+ false,
2079
+ ['sign', 'verify']
2080
+ );
2081
+
2082
+ const signatureBuffer = hexToArrayBuffer(sig);
2083
+ const isValid = await crypto.subtle.verify(
2084
+ 'HMAC',
2085
+ key,
2086
+ signatureBuffer,
2087
+ encoder.encode(dataToSign)
2088
+ );
2089
+
2090
+ if (!isValid) {
2091
+ return c.json({
2092
+ error: 'signature_invalid',
2093
+ message: 'Signature verification failed'
2094
+ }, 403);
1631
2095
  }
1632
2096
 
1633
- if (job.status === 'failed') {
1634
- response.error = job.error;
2097
+ return next();
2098
+ }
2099
+
2100
+ function hexToArrayBuffer(hex: string): ArrayBuffer {
2101
+ const bytes = new Uint8Array(hex.length / 2);
2102
+ for (let i = 0; i < hex.length; i += 2) {
2103
+ bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
1635
2104
  }
2105
+ return bytes.buffer;
2106
+ }
2107
+ ```
2108
+
2109
+ **Signing key routes:**
2110
+
2111
+ ```typescript
2112
+ // src/routes/signing-keys.ts
2113
+ import { Hono } from 'hono';
2114
+ import type { Env } from '../types/env';
2115
+ import { authMiddleware, requireOrgAccess } from '../middleware/auth';
2116
+ import { generateId } from '../lib/id';
2117
+ import { hashPassword } from '../lib/password';
2118
+ import { nanoid } from 'nanoid';
2119
+
2120
+ const signingKeys = new Hono<{ Bindings: Env }>();
1636
2121
 
1637
- return c.json(response);
2122
+ signingKeys.use('*', authMiddleware);
2123
+
2124
+ // GET /organizations/:orgId/signing-keys - List signing keys
2125
+ signingKeys.get('/:orgId', requireOrgAccess('admin'), async (c) => {
2126
+ const orgId = c.get('orgId')!;
2127
+
2128
+ const { results } = await c.env.DB.prepare(`
2129
+ SELECT id, key_prefix, created_at FROM signing_keys
2130
+ WHERE org_id = ? AND revoked_at IS NULL
2131
+ ORDER BY created_at DESC
2132
+ `).bind(orgId).all();
2133
+
2134
+ return c.json({ keys: results });
1638
2135
  });
1639
2136
 
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
- }
2137
+ // POST /organizations/:orgId/signing-keys - Create signing key
2138
+ signingKeys.post('/:orgId', requireOrgAccess('admin'), async (c) => {
2139
+ const orgId = c.get('orgId')!;
1672
2140
 
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');
2141
+ const keyId = `sk_${nanoid(16)}`;
2142
+ const rawKey = `sk_live_${nanoid(32)}`;
2143
+ const keyHash = await hashPassword(rawKey);
2144
+ const keyPrefix = rawKey.substring(0, 12) + '****';
1676
2145
 
1677
- if (!mainFile) {
1678
- throw new Error('Template file not found');
1679
- }
2146
+ await c.env.DB.prepare(`
2147
+ INSERT INTO signing_keys (id, org_id, key_hash, key_prefix)
2148
+ VALUES (?, ?, ?, ?)
2149
+ `).bind(keyId, orgId, keyHash, keyPrefix).run();
2150
+
2151
+ return c.json({
2152
+ id: keyId,
2153
+ key: rawKey, // Only shown once!
2154
+ prefix: keyPrefix,
2155
+ createdAt: new Date().toISOString(),
2156
+ }, 201);
2157
+ });
2158
+
2159
+ // DELETE /organizations/:orgId/signing-keys/:id - Revoke signing key
2160
+ signingKeys.delete('/:orgId/signing-keys/:id', requireOrgAccess('admin'), async (c) => {
2161
+ const orgId = c.get('orgId')!;
2162
+ const keyId = c.req.param('id');
1680
2163
 
1681
- const object = await env.STORAGE.get(mainFile.r2Key);
1682
- if (!object) {
1683
- throw new Error('Template file not found in storage');
2164
+ const result = await c.env.DB.prepare(`
2165
+ UPDATE signing_keys SET revoked_at = datetime('now')
2166
+ WHERE id = ? AND org_id = ?
2167
+ `).bind(keyId, orgId).run();
2168
+
2169
+ if (result.meta.changes === 0) {
2170
+ return c.json({ error: 'Key not found' }, 404);
1684
2171
  }
1685
2172
 
1686
- return await object.text();
2173
+ return c.json({ success: true });
2174
+ });
2175
+
2176
+ export default signingKeys;
2177
+ ```
2178
+
2179
+ **Integration in render handler:**
2180
+
2181
+ ```typescript
2182
+ // In handleRender (render.ts), add signature verification
2183
+ import { verifySignature } from '../middleware/signature';
2184
+
2185
+ render.get('/:token', async (c) => {
2186
+ // ... lookup render token ...
2187
+
2188
+ // Load template meta and attach to context
2189
+ const templateMeta = await getTemplateMeta(tokenData.template_id, env);
2190
+ c.set('templateMeta', templateMeta);
2191
+ c.set('orgId', tokenData.org_id);
2192
+
2193
+ // Verify signature if required
2194
+ const signatureResult = await verifySignature(c, () => Promise.resolve());
2195
+ if (signatureResult) return signatureResult; // Returns error response if invalid
2196
+
2197
+ // Continue with normal rendering...
2198
+ });
2199
+ ```
2200
+
2201
+ **Client-side SDK for generating signed URLs:**
2202
+
2203
+ ```typescript
2204
+ // Published as @loopwind/sign or included in main SDK
2205
+
2206
+ import crypto from 'crypto';
2207
+
2208
+ export interface SignOptions {
2209
+ expiresIn?: number; // seconds (default: 3600)
1687
2210
  }
1688
2211
 
1689
- function getNextMonthReset(): string {
1690
- const now = new Date();
1691
- return new Date(now.getFullYear(), now.getMonth() + 1, 1).toISOString();
2212
+ export function signRenderUrl(
2213
+ renderUrl: string,
2214
+ signingKey: string,
2215
+ options: SignOptions = {}
2216
+ ): string {
2217
+ const { expiresIn = 3600 } = options;
2218
+
2219
+ const url = new URL(renderUrl);
2220
+ const exp = Math.floor(Date.now() / 1000) + expiresIn;
2221
+
2222
+ // Add expiration
2223
+ url.searchParams.set('exp', String(exp));
2224
+
2225
+ // Sort params for consistent signing
2226
+ url.searchParams.sort();
2227
+
2228
+ // Generate signature
2229
+ const dataToSign = url.pathname + url.search;
2230
+ const sig = crypto
2231
+ .createHmac('sha256', signingKey)
2232
+ .update(dataToSign)
2233
+ .digest('hex');
2234
+
2235
+ url.searchParams.set('sig', sig);
2236
+ return url.toString();
1692
2237
  }
1693
2238
 
1694
- export default render;
2239
+ // Edge-compatible version (for Cloudflare Workers, Vercel Edge, etc.)
2240
+ export async function signRenderUrlEdge(
2241
+ renderUrl: string,
2242
+ signingKey: string,
2243
+ options: SignOptions = {}
2244
+ ): Promise<string> {
2245
+ const { expiresIn = 3600 } = options;
2246
+
2247
+ const url = new URL(renderUrl);
2248
+ const exp = Math.floor(Date.now() / 1000) + expiresIn;
2249
+
2250
+ url.searchParams.set('exp', String(exp));
2251
+ url.searchParams.sort();
2252
+
2253
+ const dataToSign = url.pathname + url.search;
2254
+
2255
+ const encoder = new TextEncoder();
2256
+ const key = await crypto.subtle.importKey(
2257
+ 'raw',
2258
+ encoder.encode(signingKey),
2259
+ { name: 'HMAC', hash: 'SHA-256' },
2260
+ false,
2261
+ ['sign']
2262
+ );
2263
+
2264
+ const signature = await crypto.subtle.sign(
2265
+ 'HMAC',
2266
+ key,
2267
+ encoder.encode(dataToSign)
2268
+ );
2269
+
2270
+ const sig = Array.from(new Uint8Array(signature))
2271
+ .map(b => b.toString(16).padStart(2, '0'))
2272
+ .join('');
2273
+
2274
+ url.searchParams.set('sig', sig);
2275
+ return url.toString();
2276
+ }
1695
2277
  ```
1696
2278
 
1697
- ### 4.2 Render Job Durable Object
2279
+ ### 4.4 Render Job Durable Object
1698
2280
 
1699
2281
  ```typescript
1700
2282
  // src/durable-objects/RenderJobOrchestrator.ts
@@ -1948,79 +2530,104 @@ app.listen(PORT, () => {
1948
2530
  import { Hono } from 'hono';
1949
2531
  import { z } from 'zod';
1950
2532
  import type { Env } from '../types/env';
1951
- import { authMiddleware, requireRole } from '../middleware/auth';
2533
+ import { authMiddleware, requireOrgAccess } from '../middleware/auth';
1952
2534
  import { generateId, generateInviteToken } from '../lib/id';
1953
2535
 
1954
2536
  const orgs = new Hono<{ Bindings: Env }>();
1955
2537
 
1956
2538
  orgs.use('*', authMiddleware);
1957
2539
 
1958
- // GET /organizations/current - Get current org
1959
- orgs.get('/current', async (c) => {
2540
+ // GET /organizations - List user's organizations
2541
+ orgs.get('/', async (c) => {
1960
2542
  const user = c.get('user');
1961
2543
 
2544
+ const { results } = await c.env.DB.prepare(`
2545
+ SELECT t.*, tm.role,
2546
+ (SELECT COUNT(*) FROM org_members WHERE org_id = t.id) as member_count,
2547
+ (SELECT COUNT(*) FROM templates WHERE org_id = t.id) as template_count
2548
+ FROM organizations o
2549
+ JOIN org_members tm ON t.id = tm.org_id
2550
+ WHERE tm.user_id = ?
2551
+ `).bind(user.sub).all();
2552
+
2553
+ return c.json({ organizations: results });
2554
+ });
2555
+
2556
+ // GET /organizations/:orgId - Get organization details
2557
+ orgs.get('/:orgId', requireOrgAccess(), async (c) => {
2558
+ const orgId = c.get('orgId')!;
2559
+
1962
2560
  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
2561
+ SELECT t.*,
2562
+ (SELECT COUNT(*) FROM org_members WHERE org_id = t.id) as member_count,
2563
+ (SELECT COUNT(*) FROM templates WHERE org_id = t.id) as template_count
1966
2564
  FROM organizations o
1967
- WHERE o.id = ?
1968
- `).bind(user.org).first();
2565
+ WHERE t.id = ?
2566
+ `).bind(orgId).first();
1969
2567
 
1970
2568
  return c.json(org);
1971
2569
  });
1972
2570
 
1973
- // PUT /organizations/current - Update org
1974
- orgs.put('/current', requireRole('owner', 'admin'), async (c) => {
1975
- const user = c.get('user');
2571
+ // PUT /organizations/:orgId - Update organization
2572
+ orgs.put('/:orgId', requireOrgAccess('admin'), async (c) => {
2573
+ const orgId = c.get('orgId')!;
1976
2574
  const { name } = await c.req.json();
1977
2575
 
1978
2576
  if (name) {
1979
2577
  await c.env.DB.prepare(
1980
2578
  'UPDATE organizations SET name = ?, updated_at = datetime("now") WHERE id = ?'
1981
- ).bind(name, user.org).run();
2579
+ ).bind(name, orgId).run();
1982
2580
  }
1983
2581
 
1984
2582
  return c.json({ success: true });
1985
2583
  });
1986
2584
 
1987
- // GET /organizations/current/members - List members
1988
- orgs.get('/current/members', async (c) => {
1989
- const user = c.get('user');
2585
+ // GET /organizations/:orgId/members - List members
2586
+ orgs.get('/:orgId/members', requireOrgAccess(), async (c) => {
2587
+ const orgId = c.get('orgId')!;
1990
2588
 
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();
2589
+ const { results } = await c.env.DB.prepare(`
2590
+ SELECT u.id, u.email, u.name, u.avatar_url, u.github_username, tm.role, tm.created_at
2591
+ FROM users u
2592
+ JOIN org_members tm ON u.id = tm.user_id
2593
+ WHERE tm.org_id = ?
2594
+ `).bind(orgId).all();
1995
2595
 
1996
- return c.json({ members: members.results });
2596
+ return c.json({ members: results });
1997
2597
  });
1998
2598
 
1999
- // POST /organizations/current/invitations - Invite user
2000
- orgs.post('/current/invitations', requireRole('owner', 'admin'), async (c) => {
2599
+ // POST /organizations/:orgId/invitations - Invite user
2600
+ orgs.post('/:orgId/invitations', requireOrgAccess('admin'), async (c) => {
2001
2601
  const user = c.get('user');
2602
+ const orgId = c.get('orgId')!;
2002
2603
  const { email, role = 'member' } = await c.req.json();
2003
2604
 
2004
2605
  // Check member limit
2005
2606
  const org = await c.env.DB.prepare(
2006
2607
  'SELECT members_limit FROM organizations WHERE id = ?'
2007
- ).bind(user.org).first<any>();
2608
+ ).bind(orgId).first<any>();
2008
2609
 
2009
2610
  const memberCount = await c.env.DB.prepare(
2010
- 'SELECT COUNT(*) as count FROM users WHERE org_id = ?'
2011
- ).bind(user.org).first<{ count: number }>();
2611
+ 'SELECT COUNT(*) as count FROM org_members WHERE org_id = ?'
2612
+ ).bind(orgId).first<{ count: number }>();
2012
2613
 
2013
2614
  if (memberCount!.count >= org.members_limit) {
2014
2615
  return c.json({ error: 'Member limit reached' }, 403);
2015
2616
  }
2016
2617
 
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();
2618
+ // Check if user already in organization
2619
+ const existingUser = await c.env.DB.prepare(
2620
+ 'SELECT id FROM users WHERE email = ?'
2621
+ ).bind(email).first<{ id: string }>();
2622
+
2623
+ if (existingUser) {
2624
+ const existingMember = await c.env.DB.prepare(
2625
+ 'SELECT 1 FROM org_members WHERE org_id = ? AND user_id = ?'
2626
+ ).bind(orgId, existingUser.id).first();
2021
2627
 
2022
- if (existing) {
2023
- return c.json({ error: 'User already in organization' }, 409);
2628
+ if (existingMember) {
2629
+ return c.json({ error: 'User already in organization' }, 409);
2630
+ }
2024
2631
  }
2025
2632
 
2026
2633
  // Create invitation
@@ -2031,7 +2638,7 @@ orgs.post('/current/invitations', requireRole('owner', 'admin'), async (c) => {
2031
2638
  await c.env.DB.prepare(`
2032
2639
  INSERT INTO invitations (id, org_id, invited_by, email, role, token, expires_at)
2033
2640
  VALUES (?, ?, ?, ?, ?, ?, ?)
2034
- `).bind(inviteId, user.org, user.sub, email, role, token, expiresAt).run();
2641
+ `).bind(inviteId, orgId, user.sub, email, role, token, expiresAt).run();
2035
2642
 
2036
2643
  // TODO: Send invitation email
2037
2644
 
@@ -2039,14 +2646,15 @@ orgs.post('/current/invitations', requireRole('owner', 'admin'), async (c) => {
2039
2646
  id: inviteId,
2040
2647
  email,
2041
2648
  role,
2042
- inviteUrl: `https://loopwind.dev/invite/${token}`,
2649
+ inviteUrl: `https://app.loopwind.dev/invite/${token}`,
2043
2650
  expiresAt,
2044
2651
  }, 201);
2045
2652
  });
2046
2653
 
2047
- // DELETE /organizations/current/members/:userId - Remove member
2048
- orgs.delete('/current/members/:userId', requireRole('owner', 'admin'), async (c) => {
2654
+ // DELETE /organizations/:orgId/members/:userId - Remove member
2655
+ orgs.delete('/:orgId/members/:userId', requireOrgAccess('admin'), async (c) => {
2049
2656
  const user = c.get('user');
2657
+ const orgId = c.get('orgId')!;
2050
2658
  const userId = c.req.param('userId');
2051
2659
 
2052
2660
  // Can't remove yourself
@@ -2055,19 +2663,21 @@ orgs.delete('/current/members/:userId', requireRole('owner', 'admin'), async (c)
2055
2663
  }
2056
2664
 
2057
2665
  // 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 }>();
2666
+ const targetMember = await c.env.DB.prepare(
2667
+ 'SELECT role FROM org_members WHERE org_id = ? AND user_id = ?'
2668
+ ).bind(orgId, userId).first<{ role: string }>();
2061
2669
 
2062
- if (!targetUser) {
2063
- return c.json({ error: 'User not found' }, 404);
2670
+ if (!targetMember) {
2671
+ return c.json({ error: 'User not found in organization' }, 404);
2064
2672
  }
2065
2673
 
2066
- if (targetUser.role === 'owner') {
2674
+ if (targetMember.role === 'owner') {
2067
2675
  return c.json({ error: 'Cannot remove owner' }, 403);
2068
2676
  }
2069
2677
 
2070
- await c.env.DB.prepare('DELETE FROM users WHERE id = ?').bind(userId).run();
2678
+ await c.env.DB.prepare(
2679
+ 'DELETE FROM org_members WHERE org_id = ? AND user_id = ?'
2680
+ ).bind(orgId, userId).run();
2071
2681
 
2072
2682
  return c.json({ success: true });
2073
2683
  });
@@ -2082,7 +2692,7 @@ export default orgs;
2082
2692
  import { Hono } from 'hono';
2083
2693
  import { z } from 'zod';
2084
2694
  import type { Env } from '../types/env';
2085
- import { authMiddleware } from '../middleware/auth';
2695
+ import { authMiddleware, requireOrgAccess } from '../middleware/auth';
2086
2696
  import { generateId, generateApiKey } from '../lib/id';
2087
2697
  import { hashPassword } from '../lib/password';
2088
2698
 
@@ -2096,26 +2706,27 @@ const createKeySchema = z.object({
2096
2706
  expiresIn: z.number().optional(), // Days until expiration
2097
2707
  });
2098
2708
 
2099
- // GET /keys - List API keys
2100
- keys.get('/', async (c) => {
2101
- const user = c.get('user');
2709
+ // GET /organizations/:orgId/keys - List API keys for organization
2710
+ keys.get('/:orgId', requireOrgAccess(), async (c) => {
2711
+ const orgId = c.get('orgId')!;
2102
2712
 
2103
- const apiKeys = await c.env.DB.prepare(`
2713
+ const { results } = await c.env.DB.prepare(`
2104
2714
  SELECT id, name, key_prefix, scopes, last_used_at, expires_at, created_at
2105
2715
  FROM api_keys WHERE org_id = ?
2106
- `).bind(user.org).all();
2716
+ `).bind(orgId).all();
2107
2717
 
2108
2718
  return c.json({
2109
- keys: apiKeys.results.map((k: any) => ({
2719
+ keys: results.map((k: any) => ({
2110
2720
  ...k,
2111
2721
  scopes: JSON.parse(k.scopes),
2112
2722
  })),
2113
2723
  });
2114
2724
  });
2115
2725
 
2116
- // POST /keys - Create API key
2117
- keys.post('/', async (c) => {
2726
+ // POST /organizations/:orgId/keys - Create API key for organization
2727
+ keys.post('/:orgId', requireOrgAccess('admin'), async (c) => {
2118
2728
  const user = c.get('user');
2729
+ const orgId = c.get('orgId')!;
2119
2730
  const body = await c.req.json();
2120
2731
 
2121
2732
  const parsed = createKeySchema.safeParse(body);
@@ -2136,7 +2747,7 @@ keys.post('/', async (c) => {
2136
2747
  await c.env.DB.prepare(`
2137
2748
  INSERT INTO api_keys (id, org_id, user_id, name, key_hash, key_prefix, scopes, expires_at)
2138
2749
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2139
- `).bind(keyId, user.org, user.sub, name, keyHash, prefix, JSON.stringify(scopes), expiresAt).run();
2750
+ `).bind(keyId, orgId, user.sub, name, keyHash, prefix, JSON.stringify(scopes), expiresAt).run();
2140
2751
 
2141
2752
  return c.json({
2142
2753
  id: keyId,
@@ -2149,14 +2760,14 @@ keys.post('/', async (c) => {
2149
2760
  }, 201);
2150
2761
  });
2151
2762
 
2152
- // DELETE /keys/:id - Revoke API key
2153
- keys.delete('/:id', async (c) => {
2154
- const user = c.get('user');
2763
+ // DELETE /organizations/:orgId/keys/:id - Revoke API key
2764
+ keys.delete('/:orgId/keys/:id', requireOrgAccess('admin'), async (c) => {
2765
+ const orgId = c.get('orgId')!;
2155
2766
  const keyId = c.req.param('id');
2156
2767
 
2157
2768
  const result = await c.env.DB.prepare(
2158
2769
  'DELETE FROM api_keys WHERE id = ? AND org_id = ?'
2159
- ).bind(keyId, user.org).run();
2770
+ ).bind(keyId, orgId).run();
2160
2771
 
2161
2772
  if (result.meta.changes === 0) {
2162
2773
  return c.json({ error: 'API key not found' }, 404);
@@ -2188,14 +2799,14 @@ import render from './routes/render';
2188
2799
  import keys from './routes/keys';
2189
2800
 
2190
2801
  import { rateLimitMiddleware } from './middleware/rateLimit';
2191
- import { RenderJobOrchestrator } from './durable-objects/RenderJobOrchestrator';
2802
+ import { OrgDurableObject } from './durable-objects/OrgDurableObject';
2192
2803
 
2193
2804
  const app = new Hono<{ Bindings: Env }>();
2194
2805
 
2195
2806
  // Global middleware
2196
2807
  app.use('*', logger());
2197
2808
  app.use('*', cors({
2198
- origin: ['https://loopwind.dev', 'http://localhost:3000'],
2809
+ origin: ['https://loopwind.dev', 'https://app.loopwind.dev', 'http://localhost:3000', 'http://localhost:4321'],
2199
2810
  credentials: true,
2200
2811
  }));
2201
2812
 
@@ -2205,36 +2816,242 @@ app.get('/health', (c) => c.json({ status: 'ok' }));
2205
2816
  // Public routes
2206
2817
  app.route('/auth', auth);
2207
2818
 
2819
+ // Public render API (no auth required - uses render tokens)
2820
+ app.route('/r', render);
2821
+
2208
2822
  // Protected routes with rate limiting
2209
2823
  app.use('/organizations/*', rateLimitMiddleware);
2210
- app.use('/templates/*', rateLimitMiddleware);
2211
- app.use('/render/*', rateLimitMiddleware);
2212
- app.use('/keys/*', rateLimitMiddleware);
2213
2824
 
2214
2825
  app.route('/organizations', organizations);
2215
- app.route('/templates', templates);
2216
- app.route('/render', render);
2217
- app.route('/keys', keys);
2826
+ app.route('/organizations', templates); // /organizations/:orgId/templates/...
2827
+ app.route('/organizations', keys); // /organizations/:orgId/keys/...
2828
+
2829
+ // Custom domain support - proxy requests from og.mysite.com
2830
+ app.get('*', async (c) => {
2831
+ const host = c.req.header('host');
2832
+
2833
+ // Check if this is a custom domain request
2834
+ if (host && !host.includes('loopwind.dev') && !host.includes('localhost')) {
2835
+ const customDomain = await c.env.DB.prepare(
2836
+ 'SELECT org_id FROM custom_domains WHERE domain = ? AND verified_at IS NOT NULL'
2837
+ ).bind(host).first<{ org_id: string }>();
2838
+
2839
+ if (customDomain) {
2840
+ // Get default render token for this organization
2841
+ const token = await c.env.DB.prepare(
2842
+ 'SELECT token FROM render_tokens WHERE org_id = ? AND revoked_at IS NULL LIMIT 1'
2843
+ ).bind(customDomain.org_id).first<{ token: string }>();
2844
+
2845
+ if (token) {
2846
+ // Forward to render handler
2847
+ const url = new URL(c.req.url);
2848
+ url.pathname = `/r/${token.token}`;
2849
+ return c.redirect(url.toString(), 307);
2850
+ }
2851
+ }
2852
+ }
2218
2853
 
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
- });
2854
+ return c.json({ error: 'Not found' }, 404);
2227
2855
  });
2228
2856
 
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
- });
2857
+ // Export Durable Object class
2858
+ export { OrgDurableObject };
2859
+
2860
+ export default app;
2861
+ ```
2862
+
2863
+ ### 7.2 Analytics Routes (Render/Hit Stats)
2864
+
2865
+ ```typescript
2866
+ // src/routes/analytics.ts
2867
+ import { Hono } from 'hono';
2868
+ import type { Env } from '../types/env';
2869
+ import { authMiddleware, requireOrgAccess } from '../middleware/auth';
2870
+
2871
+ const analytics = new Hono<{ Bindings: Env }>();
2872
+
2873
+ analytics.use('*', authMiddleware);
2874
+
2875
+ // GET /organizations/:orgId/analytics - Get organization analytics overview
2876
+ analytics.get('/:orgId', requireOrgAccess(), async (c) => {
2877
+ const orgId = c.get('orgId')!;
2878
+ const { period = '7d' } = c.req.query();
2879
+
2880
+ // Query Analytics Engine
2881
+ const stats = await queryAnalytics(c.env, orgId, period);
2882
+
2883
+ return c.json(stats);
2236
2884
  });
2237
2885
 
2886
+ // GET /organizations/:orgId/analytics/templates/:templateId - Get template-specific analytics
2887
+ analytics.get('/:orgId/templates/:templateId', requireOrgAccess(), async (c) => {
2888
+ const orgId = c.get('orgId')!;
2889
+ const templateId = c.req.param('templateId');
2890
+ const { period = '7d' } = c.req.query();
2891
+
2892
+ const stats = await queryTemplateAnalytics(c.env, orgId, templateId, period);
2893
+
2894
+ return c.json(stats);
2895
+ });
2896
+
2897
+ async function queryAnalytics(env: Env, orgId: string, period: string) {
2898
+ // Analytics Engine SQL API
2899
+ const days = period === '30d' ? 30 : period === '7d' ? 7 : 1;
2900
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
2901
+
2902
+ // This would use the Analytics Engine SQL API
2903
+ // For now, return mock structure
2904
+ return {
2905
+ period,
2906
+ totalRenders: 0,
2907
+ totalHits: 0,
2908
+ uniqueTemplates: 0,
2909
+ byTemplate: [],
2910
+ byDay: [],
2911
+ hitRatio: 0, // hits / (renders + hits)
2912
+ };
2913
+ }
2914
+
2915
+ async function queryTemplateAnalytics(env: Env, orgId: string, templateId: string, period: string) {
2916
+ return {
2917
+ templateId,
2918
+ period,
2919
+ renders: 0,
2920
+ hits: 0,
2921
+ byFormat: {},
2922
+ byDay: [],
2923
+ };
2924
+ }
2925
+
2926
+ export default analytics;
2927
+ ```
2928
+
2929
+ ### 7.3 Organization Durable Object (Template Storage)
2930
+
2931
+ ```typescript
2932
+ // src/durable-objects/OrgDurableObject.ts
2933
+ import { DurableObject } from 'cloudflare:workers';
2934
+ import type { Env } from '../types/env';
2935
+
2936
+ export class OrgDurableObject extends DurableObject {
2937
+ private sql: SqlStorage;
2938
+
2939
+ constructor(ctx: DurableObjectState, env: Env) {
2940
+ super(ctx, env);
2941
+ this.sql = ctx.storage.sql;
2942
+
2943
+ // Initialize SQLite schema
2944
+ this.sql.exec(`
2945
+ CREATE TABLE IF NOT EXISTS templates (
2946
+ id TEXT PRIMARY KEY,
2947
+ name TEXT NOT NULL,
2948
+ type TEXT NOT NULL,
2949
+ current_version TEXT
2950
+ );
2951
+
2952
+ CREATE TABLE IF NOT EXISTS template_versions (
2953
+ id TEXT PRIMARY KEY,
2954
+ template_id TEXT NOT NULL,
2955
+ version TEXT NOT NULL,
2956
+ meta TEXT NOT NULL,
2957
+ UNIQUE(template_id, version)
2958
+ );
2959
+
2960
+ CREATE TABLE IF NOT EXISTS template_files (
2961
+ id TEXT PRIMARY KEY,
2962
+ version_id TEXT NOT NULL,
2963
+ path TEXT NOT NULL,
2964
+ content TEXT NOT NULL,
2965
+ checksum TEXT NOT NULL
2966
+ );
2967
+ `);
2968
+ }
2969
+
2970
+ async fetch(request: Request): Promise<Response> {
2971
+ const url = new URL(request.url);
2972
+ const path = url.pathname;
2973
+
2974
+ // GET /template/:id/:version - Get template code and meta
2975
+ const templateMatch = path.match(/^\/template\/([^/]+)\/([^/]+)$/);
2976
+ if (templateMatch && request.method === 'GET') {
2977
+ const [, templateId, version] = templateMatch;
2978
+ return this.getTemplate(templateId, version);
2979
+ }
2980
+
2981
+ // POST /template - Store template version
2982
+ if (path === '/template' && request.method === 'POST') {
2983
+ const data = await request.json();
2984
+ return this.storeTemplate(data);
2985
+ }
2986
+
2987
+ return new Response('Not found', { status: 404 });
2988
+ }
2989
+
2990
+ private async getTemplate(templateId: string, version: string): Promise<Response> {
2991
+ // Get version
2992
+ const versionRow = this.sql.exec(`
2993
+ SELECT * FROM template_versions
2994
+ WHERE template_id = ? AND version = ?
2995
+ `, templateId, version).one();
2996
+
2997
+ if (!versionRow) {
2998
+ return new Response('Not found', { status: 404 });
2999
+ }
3000
+
3001
+ // Get files
3002
+ const files = this.sql.exec(`
3003
+ SELECT path, content FROM template_files
3004
+ WHERE version_id = ?
3005
+ `, versionRow.id).toArray();
3006
+
3007
+ const code = files.find(f => f.path === 'template.tsx')?.content || '';
3008
+ const assets: Record<string, string> = {};
3009
+ for (const file of files) {
3010
+ if (file.path !== 'template.tsx') {
3011
+ assets[file.path] = file.content;
3012
+ }
3013
+ }
3014
+
3015
+ return Response.json({
3016
+ code,
3017
+ meta: JSON.parse(versionRow.meta as string),
3018
+ assets,
3019
+ });
3020
+ }
3021
+
3022
+ private async storeTemplate(data: any): Promise<Response> {
3023
+ const { templateId, name, type, version, meta, files } = data;
3024
+
3025
+ // Upsert template
3026
+ this.sql.exec(`
3027
+ INSERT INTO templates (id, name, type, current_version)
3028
+ VALUES (?, ?, ?, ?)
3029
+ ON CONFLICT(id) DO UPDATE SET current_version = excluded.current_version
3030
+ `, templateId, name, type, version);
3031
+
3032
+ // Insert version
3033
+ const versionId = `ver_${crypto.randomUUID().slice(0, 16)}`;
3034
+ this.sql.exec(`
3035
+ INSERT INTO template_versions (id, template_id, version, meta)
3036
+ VALUES (?, ?, ?, ?)
3037
+ `, versionId, templateId, version, JSON.stringify(meta));
3038
+
3039
+ // Insert files
3040
+ for (const file of files) {
3041
+ const fileId = `file_${crypto.randomUUID().slice(0, 16)}`;
3042
+ this.sql.exec(`
3043
+ INSERT INTO template_files (id, version_id, path, content, checksum)
3044
+ VALUES (?, ?, ?, ?, ?)
3045
+ `, fileId, versionId, file.path, file.content, file.checksum);
3046
+ }
3047
+
3048
+ return Response.json({ success: true, versionId });
3049
+ }
3050
+ }
3051
+ ```
3052
+
3053
+ ```typescript
3054
+ // Old job callback routes removed - video rendering uses containers directly
2238
3055
  app.post('/internal/jobs/:jobId/fail', async (c) => {
2239
3056
  const jobId = c.req.param('jobId');
2240
3057
  const jobDO = c.env.RENDER_JOBS.get(c.env.RENDER_JOBS.idFromName(jobId));
@@ -2383,7 +3200,7 @@ website/
2383
3200
  │ │ ├── settings/
2384
3201
  │ │ │ ├── index.astro # Account settings
2385
3202
  │ │ │ ├── api-keys.astro # API key management
2386
- │ │ │ └── team.astro # Team/org management
3203
+ │ │ │ └── organization.astro # Organization management
2387
3204
  │ │ └── billing.astro # Billing/plan
2388
3205
  │ ├── components/
2389
3206
  │ │ └── app/ # Dashboard components
@@ -2739,7 +3556,7 @@ PUBLIC_API_URL = "https://api.loopwind.dev"
2739
3556
  - [ ] Build templates list page
2740
3557
  - [ ] Build template detail page
2741
3558
  - [ ] Build renders history page
2742
- - [ ] Build settings pages (account, API keys, team)
3559
+ - [ ] Build settings pages (account, API keys, organization)
2743
3560
  - [ ] Set up app.loopwind.dev domain
2744
3561
 
2745
3562
  ---