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.
- package/app/.astro/types.d.ts +1 -0
- package/app/dist/_astro/callback.Ci5gaEfJ.css +1 -0
- package/app/dist/auth/callback/index.html +81 -0
- package/app/dist/device/index.html +70 -0
- package/app/dist/index.html +327 -0
- package/app/package-lock.json +9239 -0
- package/app/package.json +23 -0
- package/app/wrangler.toml +8 -0
- package/dist/cli.js +54 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/login.d.ts +5 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +60 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +5 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +15 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/publish.d.ts +10 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/publish.js +155 -0
- package/dist/commands/publish.js.map +1 -0
- package/dist/commands/templates.d.ts +5 -0
- package/dist/commands/templates.d.ts.map +1 -0
- package/dist/commands/templates.js +60 -0
- package/dist/commands/templates.js.map +1 -0
- package/dist/commands/unpublish.d.ts +5 -0
- package/dist/commands/unpublish.d.ts.map +1 -0
- package/dist/commands/unpublish.js +54 -0
- package/dist/commands/unpublish.js.map +1 -0
- package/dist/commands/whoami.d.ts +5 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +30 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/lib/api.d.ts +92 -0
- package/dist/lib/api.d.ts.map +1 -0
- package/dist/lib/api.js +149 -0
- package/dist/lib/api.js.map +1 -0
- package/dist/lib/auth.d.ts +41 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/auth.js +89 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/bundler.d.ts +18 -0
- package/dist/lib/bundler.d.ts.map +1 -0
- package/dist/lib/bundler.js +105 -0
- package/dist/lib/bundler.js.map +1 -0
- package/dist/lib/helpers.d.ts +35 -2
- package/dist/lib/helpers.d.ts.map +1 -1
- package/dist/lib/helpers.js +91 -13
- package/dist/lib/helpers.js.map +1 -1
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +9 -0
- package/dist/lib/utils.js.map +1 -1
- package/dist/sdk/edge.d.ts +65 -0
- package/dist/sdk/edge.d.ts.map +1 -0
- package/dist/sdk/edge.js +359 -0
- package/dist/sdk/edge.js.map +1 -0
- package/dist/sdk/errors.d.ts +64 -0
- package/dist/sdk/errors.d.ts.map +1 -0
- package/dist/sdk/errors.js +94 -0
- package/dist/sdk/errors.js.map +1 -0
- package/dist/sdk/index.d.ts +29 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +30 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/sdk/render.d.ts +52 -0
- package/dist/sdk/render.d.ts.map +1 -0
- package/dist/sdk/render.js +432 -0
- package/dist/sdk/render.js.map +1 -0
- package/dist/sdk/types.d.ts +185 -0
- package/dist/sdk/types.d.ts.map +1 -0
- package/dist/sdk/types.js +5 -0
- package/dist/sdk/types.js.map +1 -0
- package/dist/types/template.d.ts +18 -0
- package/dist/types/template.d.ts.map +1 -1
- package/package.json +27 -4
- package/plans/PLATFORM.md +1637 -237
- package/plans/PLATFORM_IMPLEMENTATION.md +1347 -530
- package/plans/SDK.md +797 -0
- package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite +0 -0
- package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite-shm +0 -0
- package/platform/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/ebad93a0a7be9c5768c512f3e30740b64d2b6e575277a40d77044af5ae8fd3f2.sqlite-wal +0 -0
- package/platform/migrations/0001_initial.sql +90 -0
- package/platform/package-lock.json +3253 -0
- package/platform/package.json +30 -0
- package/platform/wrangler.toml +43 -0
- package/tests-sdk/createRenderer.test.ts +251 -0
- package/tests-sdk/errors.test.ts +230 -0
- package/tests-sdk/render.test.ts +241 -0
- 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
|
|
18
|
-
│ Blog
|
|
19
|
-
│
|
|
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 •
|
|
34
|
-
│ • organizations • RATE_LIMIT •
|
|
35
|
-
│ •
|
|
36
|
-
│ •
|
|
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 (
|
|
49
|
-
|
|
|
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
|
|
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 #
|
|
100
|
-
│ │ │ ├── organizations.ts
|
|
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 #
|
|
104
|
-
│ │ │
|
|
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
|
-
│ │ │
|
|
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
|
|
228
|
+
# Durable Objects for organization data (templates, versions, files)
|
|
208
229
|
[[durable_objects.bindings]]
|
|
209
|
-
name = "
|
|
210
|
-
class_name = "
|
|
230
|
+
name = "ORG_DO"
|
|
231
|
+
class_name = "OrgDurableObject"
|
|
211
232
|
|
|
212
233
|
[[migrations]]
|
|
213
234
|
tag = "v1"
|
|
214
|
-
new_classes = ["
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
--
|
|
374
|
-
CREATE TABLE
|
|
375
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
414
|
+
revoked_at TEXT, -- Soft delete
|
|
415
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
384
416
|
);
|
|
385
417
|
|
|
386
|
-
CREATE INDEX
|
|
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
|
-
--
|
|
389
|
-
CREATE TABLE
|
|
390
|
-
id TEXT PRIMARY KEY, --
|
|
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
|
-
|
|
426
|
+
domain TEXT UNIQUE NOT NULL, -- og.mysite.com
|
|
427
|
+
verified_at TEXT, -- NULL until DNS verified
|
|
396
428
|
|
|
397
|
-
|
|
398
|
-
|
|
429
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
430
|
+
);
|
|
399
431
|
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
408
|
-
webhook_sent INTEGER DEFAULT 0,
|
|
440
|
+
pattern TEXT NOT NULL, -- *.example.com or cdn.example.com
|
|
409
441
|
|
|
410
|
-
|
|
411
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
559
|
+
github_id: string;
|
|
560
|
+
github_username: string;
|
|
525
561
|
email: string;
|
|
526
|
-
username: string;
|
|
527
562
|
name: string | null;
|
|
528
|
-
|
|
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 {
|
|
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
|
-
//
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
688
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
}
|
|
744
|
+
return c.redirect(`https://github.com/login/oauth/authorize?${params}`);
|
|
745
|
+
});
|
|
700
746
|
|
|
701
|
-
|
|
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
|
-
|
|
704
|
-
|
|
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
|
-
//
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
//
|
|
719
|
-
const
|
|
720
|
-
|
|
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
|
-
|
|
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 (
|
|
728
|
-
return c.
|
|
779
|
+
if (tokenData.error || !tokenData.access_token) {
|
|
780
|
+
return c.redirect('https://app.loopwind.dev/login?error=oauth_failed');
|
|
729
781
|
}
|
|
730
782
|
|
|
731
|
-
//
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
793
|
+
const githubUser = await userResponse.json<{
|
|
794
|
+
id: number;
|
|
795
|
+
login: string;
|
|
796
|
+
name: string | null;
|
|
797
|
+
avatar_url: string;
|
|
798
|
+
}>();
|
|
753
799
|
|
|
754
|
-
|
|
755
|
-
|
|
800
|
+
const emails = await emailsResponse.json<Array<{
|
|
801
|
+
email: string;
|
|
802
|
+
primary: boolean;
|
|
803
|
+
verified: boolean;
|
|
804
|
+
}>>();
|
|
756
805
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
//
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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 (!
|
|
771
|
-
|
|
772
|
-
|
|
816
|
+
if (!user) {
|
|
817
|
+
// Create new user
|
|
818
|
+
const userId = generateId.user();
|
|
773
819
|
|
|
774
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
786
|
-
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
972
|
-
|
|
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
|
-
//
|
|
1004
|
-
export function
|
|
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 (!
|
|
1009
|
-
return c.json({ error: '
|
|
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
|
|
1037
|
-
if (!
|
|
1125
|
+
const orgId = c.get('orgId');
|
|
1126
|
+
if (!orgId) return next();
|
|
1038
1127
|
|
|
1039
|
-
// Get
|
|
1128
|
+
// Get organization plan
|
|
1040
1129
|
const org = await c.env.DB.prepare(
|
|
1041
1130
|
'SELECT plan FROM organizations WHERE id = ?'
|
|
1042
|
-
).bind(
|
|
1131
|
+
).bind(orgId).first<{ plan: string }>();
|
|
1043
1132
|
|
|
1044
1133
|
const limits = PLAN_LIMITS[org?.plan || 'free'];
|
|
1045
|
-
const key = `rate:${
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
1182
|
-
description || null, meta.type,
|
|
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
|
|
1187
|
-
VALUES (?, ?,
|
|
1188
|
-
`).bind(
|
|
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.
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
-
|
|
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
|
|
1214
|
-
templates.get('
|
|
1215
|
-
const
|
|
1216
|
-
const { q, type,
|
|
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:
|
|
1516
|
+
## Phase 4: Public Render API
|
|
1426
1517
|
|
|
1427
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
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
|
-
//
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
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
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
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
|
-
|
|
1595
|
+
// 4. Resolve version
|
|
1596
|
+
const resolvedVersion = version === 'latest'
|
|
1597
|
+
? await getLatestVersion(tokenData.template_id, env)
|
|
1598
|
+
: version;
|
|
1468
1599
|
|
|
1469
|
-
// Check
|
|
1470
|
-
const
|
|
1471
|
-
|
|
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
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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: '
|
|
1628
|
+
return c.json({ error: 'Render limit exceeded' }, 429);
|
|
1487
1629
|
}
|
|
1488
1630
|
|
|
1489
|
-
// Get template
|
|
1490
|
-
const
|
|
1491
|
-
|
|
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 (
|
|
1496
|
-
return c.json({ error: '
|
|
1635
|
+
if (!templateResponse.ok) {
|
|
1636
|
+
return c.json({ error: 'Template not found' }, 404);
|
|
1497
1637
|
}
|
|
1498
1638
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1639
|
+
const templateData = await templateResponse.json<{
|
|
1640
|
+
code: string;
|
|
1641
|
+
meta: any;
|
|
1642
|
+
assets: Record<string, string>;
|
|
1643
|
+
}>();
|
|
1501
1644
|
|
|
1502
|
-
// Render image
|
|
1645
|
+
// 8. Render image
|
|
1503
1646
|
const startTime = Date.now();
|
|
1504
|
-
const imageBuffer = await renderImage(
|
|
1647
|
+
const imageBuffer = await renderImage(templateData.code, props, format, templateData.meta);
|
|
1505
1648
|
const duration = Date.now() - startTime;
|
|
1506
1649
|
|
|
1507
|
-
//
|
|
1508
|
-
await
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
-
//
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
-
//
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
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':
|
|
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
|
-
//
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
const
|
|
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
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
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
|
-
|
|
1747
|
+
return { valid: blocked.length === 0, blocked };
|
|
1748
|
+
}
|
|
1545
1749
|
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
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
|
-
|
|
1553
|
-
|
|
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
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
const totalFrames = meta.video ? meta.video.fps * meta.video.duration : 0;
|
|
1890
|
+
return transformedProps;
|
|
1891
|
+
}
|
|
1892
|
+
```
|
|
1566
1893
|
|
|
1567
|
-
|
|
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
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
1587
|
-
|
|
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
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
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
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
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
|
-
|
|
1607
|
-
SELECT * FROM render_jobs WHERE id = ? AND org_id = ?
|
|
1608
|
-
`).bind(jobId, user.org).first();
|
|
2015
|
+
**Signature verification middleware:**
|
|
1609
2016
|
|
|
1610
|
-
|
|
1611
|
-
|
|
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
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
-
|
|
1624
|
-
|
|
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
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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
|
-
|
|
1634
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1641
|
-
|
|
1642
|
-
|
|
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
|
-
|
|
1674
|
-
const
|
|
1675
|
-
const
|
|
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
|
-
|
|
1678
|
-
|
|
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
|
|
1682
|
-
|
|
1683
|
-
|
|
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
|
|
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
|
|
1690
|
-
|
|
1691
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
|
1959
|
-
orgs.get('/
|
|
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
|
|
1964
|
-
(SELECT COUNT(*) FROM
|
|
1965
|
-
(SELECT COUNT(*) FROM templates WHERE org_id =
|
|
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
|
|
1968
|
-
`).bind(
|
|
2565
|
+
WHERE t.id = ?
|
|
2566
|
+
`).bind(orgId).first();
|
|
1969
2567
|
|
|
1970
2568
|
return c.json(org);
|
|
1971
2569
|
});
|
|
1972
2570
|
|
|
1973
|
-
// PUT /organizations
|
|
1974
|
-
orgs.put('
|
|
1975
|
-
const
|
|
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,
|
|
2579
|
+
).bind(name, orgId).run();
|
|
1982
2580
|
}
|
|
1983
2581
|
|
|
1984
2582
|
return c.json({ success: true });
|
|
1985
2583
|
});
|
|
1986
2584
|
|
|
1987
|
-
// GET /organizations/
|
|
1988
|
-
orgs.get('/
|
|
1989
|
-
const
|
|
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
|
|
1992
|
-
SELECT id, email,
|
|
1993
|
-
FROM users
|
|
1994
|
-
|
|
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:
|
|
2596
|
+
return c.json({ members: results });
|
|
1997
2597
|
});
|
|
1998
2598
|
|
|
1999
|
-
// POST /organizations/
|
|
2000
|
-
orgs.post('/
|
|
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(
|
|
2608
|
+
).bind(orgId).first<any>();
|
|
2008
2609
|
|
|
2009
2610
|
const memberCount = await c.env.DB.prepare(
|
|
2010
|
-
'SELECT COUNT(*) as count FROM
|
|
2011
|
-
).bind(
|
|
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
|
|
2018
|
-
const
|
|
2019
|
-
'SELECT id FROM users WHERE email = ?
|
|
2020
|
-
).bind(email
|
|
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
|
-
|
|
2023
|
-
|
|
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,
|
|
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/
|
|
2048
|
-
orgs.delete('/
|
|
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
|
|
2059
|
-
'SELECT role FROM
|
|
2060
|
-
).bind(
|
|
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 (!
|
|
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 (
|
|
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(
|
|
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('
|
|
2101
|
-
const
|
|
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
|
|
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(
|
|
2716
|
+
`).bind(orgId).all();
|
|
2107
2717
|
|
|
2108
2718
|
return c.json({
|
|
2109
|
-
keys:
|
|
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('
|
|
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,
|
|
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
|
|
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,
|
|
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 {
|
|
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('/
|
|
2216
|
-
app.route('/
|
|
2217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
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
|
-
│ │ │ └──
|
|
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,
|
|
3559
|
+
- [ ] Build settings pages (account, API keys, organization)
|
|
2743
3560
|
- [ ] Set up app.loopwind.dev domain
|
|
2744
3561
|
|
|
2745
3562
|
---
|