From ff734ddd17d1d9f847ee5f1674e19858446e5f52 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Fri, 3 Apr 2026 10:53:29 +0200 Subject: [PATCH 001/189] Add project brief, README, and analysis documents - CLAUDE.md: technical spec and handoff brief for the OG Engine API - README.md: project overview with objectives and document index - docs/analysis/: monetization, user stories, go-to-market, feature ideas, POC source code, and landing page prototype --- CLAUDE.md | 363 ++++++++++++++++++++ README.md | 113 +++++++ docs/analysis/FEATURES-IDEAS.md | 358 ++++++++++++++++++++ docs/analysis/USER-STORIES.md | 474 ++++++++++++++++++++++++++ docs/analysis/landing-page.jsx | 311 +++++++++++++++++ docs/analysis/og-engine.jsx | 568 ++++++++++++++++++++++++++++++++ 6 files changed, 2187 insertions(+) create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 docs/analysis/FEATURES-IDEAS.md create mode 100644 docs/analysis/USER-STORIES.md create mode 100644 docs/analysis/landing-page.jsx create mode 100644 docs/analysis/og-engine.jsx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..04700cd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,363 @@ +# OG Engine — Headless Chrome Killer + +## Handoff Brief for Claude Code + +> **TL;DR:** Build a server-side image generation API powered by Pretext's text measurement engine. It replaces Puppeteer/headless Chrome for generating OG images, social cards, email banners, and dynamic visual content. Sub-5ms renders, zero browser dependencies. + +--- + +## 1. What the POC Proved + +We built a working browser-based prototype that demonstrates: + +- **Canvas-based text measurement** (Pretext's core principle) computes exact line breaks, heights, and overflow for any text — including CJK, Arabic, emoji — without DOM +- **Instant rendering** (~1-3ms) vs Puppeteer (~850ms) = **300-500x speedup** +- **Multi-format output** (OG 1200×630, Twitter, Square, LinkedIn, Story) +- **Google Fonts integration** with dynamic loading +- **Background image compositing** with overlay controls +- **PNG export** directly from Canvas + +The POC runs entirely client-side. The production version should run **server-side as an HTTP API**. + +--- + +## 2. Product Vision + +### What it is +An API service that generates images with perfectly laid-out text. Send JSON, get back a PNG/SVG/WebP. + +### Who it's for +- **SaaS platforms** generating OG/social cards per page (blogs, docs, e-commerce) +- **Email marketing tools** generating personalized banners at scale +- **Ad tech** validating creative copy fits ad unit dimensions +- **Any product** currently running Puppeteer/Playwright to render text into images + +### Why it wins +| | Puppeteer | OG Engine | +|---|---|---| +| Render time | ~850ms | ~2-5ms | +| Memory per render | ~200-500MB | ~10MB | +| Infrastructure | Chrome binary, Xvfb, sandboxing | Node.js process | +| Concurrency | ~5-10 per instance | ~500+ per instance | +| Cold start | ~2-5s | ~50ms | +| Languages | All (full browser) | All (Pretext handles bidi, CJK, emoji, grapheme clusters) | + +--- + +## 3. Technical Architecture + +### Stack + +``` +Runtime: Bun (preferred) or Node.js 20+ +Text layout: @chenglou/pretext (npm) +Canvas: @napi-rs/canvas (for server-side Canvas API) +HTTP: Hono (lightweight, works on Bun/Node/CF Workers) +Fonts: Google Fonts downloaded + cached locally +Image output: PNG (default), WebP, SVG +Deployment: Docker → Fly.io / Railway / any container platform +``` + +### Why these choices + +- **@chenglou/pretext** — the core engine. Use `prepare()` + `layout()` for height-only checks, `prepareWithSegments()` + `layoutWithLines()` when we need actual line content for rendering +- **@napi-rs/canvas** — Node-compatible Canvas API (same as browser Canvas). Fastest server-side canvas for Node/Bun. Alternative: `skia-canvas` +- **Hono** — ultra-lightweight HTTP framework, runs everywhere (Bun, Node, Cloudflare Workers, Deno) +- **Local font files** — download Google Fonts as .ttf/.woff2 and register them with the canvas. No runtime font fetching + +### Project Structure + +``` +og-engine/ +├── CLAUDE.md # This file +├── package.json +├── tsconfig.json +├── Dockerfile +├── src/ +│ ├── index.ts # HTTP server (Hono) +│ ├── api/ +│ │ ├── render.ts # POST /render endpoint +│ │ ├── validate.ts # POST /validate endpoint (text-fits-check) +│ │ └── health.ts # GET /health +│ ├── engine/ +│ │ ├── layout.ts # Pretext wrapper — text measurement & line breaking +│ │ ├── renderer.ts # Canvas rendering — composites bg, text, decorations +│ │ ├── fonts.ts # Font loading & registration +│ │ └── templates.ts # Built-in card templates +│ ├── templates/ +│ │ ├── og-default.ts # Default OG card template +│ │ ├── social-card.ts # Social media card +│ │ ├── blog-hero.ts # Blog post hero image +│ │ └── email-banner.ts +│ ├── fonts/ # Downloaded .ttf files +│ │ ├── inter/ +│ │ ├── outfit/ +│ │ ├── playfair-display/ +│ │ └── ... +│ └── utils/ +│ ├── color.ts # Color manipulation +│ ├── image.ts # Image loading/resizing +│ └── cache.ts # LRU cache for prepared text +├── tests/ +│ ├── layout.test.ts # Text measurement accuracy tests +│ ├── render.test.ts # Snapshot/visual regression tests +│ ├── api.test.ts # API endpoint tests +│ └── benchmark.ts # Performance benchmarks +└── scripts/ + └── download-fonts.ts # Script to fetch Google Fonts +``` + +--- + +## 4. API Design + +### `POST /render` + +Generate an image from text + configuration. + +**Request:** +```json +{ + "format": "og", + "template": "default", + "title": "Server-Side Text Layout Without a Browser", + "description": "Pure JS text measurement replaces Puppeteer.", + "author": "Pretext Engine", + "tag": "Open Source", + "style": { + "accent": "#38ef7d", + "layout": "left", + "font": "Outfit", + "titleSize": 48, + "descSize": 22, + "gradient": "void", + "backgroundImage": null, + "overlayOpacity": 0.65 + }, + "output": { + "format": "png", + "quality": 90 + } +} +``` + +**Response:** Binary image (PNG/WebP) with headers: +``` +Content-Type: image/png +X-Render-Time-Ms: 2.34 +X-Title-Lines: 2 +X-Desc-Lines: 3 +X-Layout-Overflow: false +``` + +### `POST /validate` + +Check if text fits a given layout WITHOUT generating an image. Ultra-fast. + +**Request:** +```json +{ + "format": "og", + "title": "Some headline", + "description": "Some body text", + "font": "Outfit", + "titleSize": 48, + "descSize": 22, + "maxTitleLines": 3, + "maxDescLines": 4 +} +``` + +**Response:** +```json +{ + "fits": true, + "title": { "lines": 2, "maxLines": 3, "overflow": false }, + "description": { "lines": 3, "maxLines": 4, "overflow": false }, + "computeTimeMs": 0.12 +} +``` + +### `POST /render/batch` + +Render multiple images in one request (for bulk generation). + +**Request:** +```json +{ + "items": [ + { "format": "og", "title": "Post 1", ... }, + { "format": "twitter", "title": "Post 2", ... } + ] +} +``` + +**Response:** Multipart or ZIP archive of images. + +### `GET /health` + +```json +{ + "status": "ok", + "fonts": ["Outfit", "Playfair Display", "Sora", ...], + "formats": ["og", "twitter", "square", "linkedin", "story"], + "templates": ["default", "social-card", "blog-hero", "email-banner"], + "version": "0.1.0" +} +``` + +--- + +## 5. Engine Implementation Notes + +### Text Layout (layout.ts) + +```typescript +import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext' + +// Wrapper that returns lines + overflow info for a text block +export function layoutText(text: string, font: string, maxWidth: number, lineHeight: number, maxLines: number) { + const prepared = prepareWithSegments(text, font) + const { lines, height, lineCount } = layoutWithLines(prepared, maxWidth, lineHeight) + + const visibleLines = lines.slice(0, maxLines) + const overflow = lineCount > maxLines + + // Add ellipsis to last visible line if overflow + if (overflow && visibleLines.length > 0) { + const last = visibleLines[visibleLines.length - 1] + visibleLines[visibleLines.length - 1] = { + ...last, + text: last.text + '…' + } + } + + return { visibleLines, totalLines: lineCount, overflow, height } +} +``` + +### Font Registration (fonts.ts) + +```typescript +import { GlobalFonts } from '@napi-rs/canvas' +import { readdir } from 'fs/promises' + +export async function registerFonts(fontsDir: string) { + // Register all .ttf files in the fonts directory + // GlobalFonts.registerFromPath(path, familyName) + // Build a map of available fonts for validation +} +``` + +### Caching Strategy + +- **Font preparation cache:** Pretext's `prepare()` results can be cached per (text, font) pair using an LRU cache. This avoids re-segmenting identical strings. +- **Image cache:** Optional Redis/memory cache keyed on request hash. Most OG images are requested repeatedly (every social share hits the same URL). + +--- + +## 6. Templates System + +Templates are functions that take structured content + style and return Canvas drawing instructions: + +```typescript +interface TemplateInput { + canvas: Canvas + ctx: CanvasRenderingContext2D + width: number + height: number + content: { title: string; description?: string; author?: string; tag?: string } + style: { accent: string; font: string; layout: string; titleSize: number; descSize: number } + backgroundImage?: Image | null + overlayOpacity?: number +} + +type Template = (input: TemplateInput) => RenderResult +``` + +Start with 4 templates: +1. **default** — accent bar, grid background, tag pill (from POC) +2. **social-card** — large centered title, minimal +3. **blog-hero** — background image focused, text overlay at bottom +4. **email-banner** — horizontal, CTA-style + +--- + +## 7. Build Priorities + +### Phase 1 — Core API (week 1) +- [ ] Project setup (Bun + Hono + TypeScript) +- [ ] Font downloading script + registration with @napi-rs/canvas +- [ ] Pretext integration for text measurement +- [ ] Canvas renderer for the default template +- [ ] `POST /render` endpoint returning PNG +- [ ] `POST /validate` endpoint +- [ ] `GET /health` endpoint +- [ ] Basic tests + benchmark script +- [ ] Dockerfile + +### Phase 2 — Production Features (week 2) +- [ ] All 4 templates +- [ ] Background image upload support (multipart form) +- [ ] WebP output option +- [ ] LRU cache for prepared text +- [ ] Request validation with Zod +- [ ] Error handling + structured error responses +- [ ] Rate limiting +- [ ] CORS configuration +- [ ] Batch endpoint + +### Phase 3 — Scale & Polish (week 3) +- [ ] Redis cache layer for rendered images +- [ ] API key authentication +- [ ] Usage tracking / metering +- [ ] OpenAPI/Swagger documentation +- [ ] SDK (TypeScript client library) +- [ ] Deploy to Fly.io with auto-scaling +- [ ] Landing page + +### Phase 4 — Growth (future) +- [ ] Custom template builder (JSON DSL) +- [ ] Webhook triggers (auto-regenerate on content update) +- [ ] Edge deployment (Cloudflare Workers — may need alternative to @napi-rs/canvas) +- [ ] AI text fitting: auto-adjust font size to fit constraints +- [ ] PDF output + +--- + +## 8. Key Decisions to Make + +1. **@napi-rs/canvas vs skia-canvas vs node-canvas?** + → Recommend @napi-rs/canvas for performance. Benchmark all three. + +2. **Bun vs Node?** + → Bun preferred for speed + native TypeScript. Ensure @napi-rs/canvas works in Bun (has native addon support since 1.0). + +3. **Pricing model?** + → Free tier: 1,000 renders/month. Pro: $29/mo for 50k renders. Scale: usage-based at $0.001/render. + +4. **Monorepo or separate repos?** + → Start monorepo: API + landing page + SDK in one repo. + +--- + +## 9. Reference + +- **Pretext repo:** https://github.com/chenglou/pretext +- **Pretext npm:** `@chenglou/pretext` +- **@napi-rs/canvas:** https://github.com/nicknisi/canvas +- **Hono:** https://hono.dev +- **POC code:** See the og-engine.jsx artifact from this conversation for the rendering logic — it's directly portable to server-side Canvas + +--- + +## 10. First Command for Claude Code + +```bash +mkdir og-engine && cd og-engine +bun init -y +bun add @chenglou/pretext @napi-rs/canvas hono zod +bun add -d typescript @types/node vitest +``` + +Then: implement `src/index.ts` with the Hono server, `src/engine/layout.ts` with Pretext integration, and `src/engine/renderer.ts` with the Canvas rendering logic ported from the POC. diff --git a/README.md b/README.md new file mode 100644 index 0000000..78d82d1 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# OG Engine — Package Projet Complet + +> **Moteur de génération d'images par API. Remplace Puppeteer. 500x plus rapide. Zéro navigateur.** +> Basé sur [Pretext](https://github.com/chenglou/pretext) par chenglou. + +--- + +## Vue d'ensemble + +OG Engine est une API qui génère des images (OG cards, bannières, visuels e-commerce) en utilisant la mesure de texte côté serveur au lieu d'un navigateur headless. Le moteur calcule les dimensions exactes de chaque ligne de texte — y compris CJK, arabe, emoji — en moins de 2ms, contre ~850ms pour Puppeteer. + +**Marché :** Tout produit qui génère des images contenant du texte dynamique. +**Modèle :** API SaaS, freemium, usage-based. Free (500/mois) → 10€ → 39€ → 99€. +**Marge :** ~99% (pas de Chrome = coût marginal quasi nul). + +--- + +## Documents inclus + +### 1. Spécification technique +**Fichier :** `CLAUDE.md` +- Architecture : Bun + Hono + Pretext + @napi-rs/canvas +- Design de l'API : 3 endpoints (render, validate, batch) +- Structure du projet avec chaque fichier décrit +- Système de templates extensible +- Roadmap technique en 4 phases +- Commandes pour démarrer avec Claude Code + +### 2. Monétisation +**Fichier :** `MONETIZATION.md` +- Intégration Stripe (Payment Links, webhooks) +- Génération et gestion des clés API +- Middleware d'authentification + compteur d'appels +- Reset mensuel automatique +- Free tier sans carte bancaire +- Envoi de clé par email (Resend) +- Schema SQLite (2 tables) + +### 3. User Stories +**Fichier :** `USER-STORIES.md` +- 4 personas : Dev, Marketer, Visitor, Admin +- 10 epics couvrant tout le produit +- 29 user stories avec critères d'acceptation +- Priorisées : 12 P0 (MVP), 11 P1 (post-launch), 6 P2 (itération) + +### 4. Plan d'acquisition & Go-to-Market +**Fichier :** `GO-TO-MARKET.md` +- 3 segments cibles (devs, SaaS, agences) +- Playbook de lancement jour par jour (HN, PH, Twitter, Reddit) +- Stratégie content marketing (6 articles SEO planifiés) +- Funnel de conversion détaillé avec taux estimés +- 8 emails automatisés (lifecycle) +- Partenariats stratégiques (CMS, frameworks, hébergeurs) +- KPIs et objectifs M1/M3/M6 +- Budget prévisionnel (15€/mois au départ) +- Analyse des risques + +### 5. Idées de fonctionnalités +**Fichier :** `FEATURES-IDEAS.md` +- 24 features en 7 catégories (IA, analytics, personnalisation, automatisation, rendu, DX, collaboration) +- Chaque feature évaluée : valeur, potentiel revenu, effort +- Matrice de priorisation : quick wins, high impact, game changers +- Top 5 : auto-fit, render depuis URL, A/B testing, variables dynamiques, env staging/prod + +### 6. Landing page +**Fichier :** `landing-page.jsx` +- Page de vente complète en React +- Hero, stats animées, exemples de code (curl, SDK, Next.js) +- Tableau comparatif Puppeteer vs OG Engine +- 4 plans tarifaires avec boutons Stripe +- FAQ interactif, CTA de conversion +- Mobile-first, responsive + +### 7. POC fonctionnel +**Fichier :** `og-engine.jsx` +- Générateur d'images OG interactif +- 4 tabs : contenu, style, background, export +- 8 Google Fonts, 8 couleurs d'accent, 6 gradients +- 5 formats (OG, Twitter, Square, LinkedIn, Story) +- Upload d'image de fond + overlay +- Download PNG + copy to clipboard +- Rendu Canvas en temps réel (~2ms) + +--- + +## Pour démarrer + +### Avec Claude Code : +``` +claude +> Read CLAUDE.md and MONETIZATION.md, then build Phase 1 of the MVP. +> Start with the Hono server, Pretext integration, and /render endpoint. +``` + +### Manuellement : +```bash +mkdir og-engine && cd og-engine +bun init -y +bun add @chenglou/pretext @napi-rs/canvas hono zod +bun add -d typescript @types/node vitest +``` + +--- + +## Objectifs + +| Horizon | Objectif | +|---------|----------| +| Semaine 1 | API fonctionnelle + landing page déployée | +| Semaine 2 | Lancement HN + PH + Twitter + Reddit | +| Mois 1 | 100 inscrits, 10 payants, 200€ MRR | +| Mois 3 | 1 000 inscrits, 80 payants, 1 500€ MRR | +| Mois 6 | 5 000 inscrits, 300 payants, 6 000€ MRR | diff --git a/docs/analysis/FEATURES-IDEAS.md b/docs/analysis/FEATURES-IDEAS.md new file mode 100644 index 0000000..c6fd666 --- /dev/null +++ b/docs/analysis/FEATURES-IDEAS.md @@ -0,0 +1,358 @@ +# OG Engine — Idées de fonctionnalités à forte valeur + +## Comment lire ce document + +Chaque feature est évaluée sur 3 axes : +- **Valeur utilisateur** : à quel point ça résout un vrai problème (★ à ★★★) +- **Potentiel de revenu** : est-ce que ça justifie un upgrade ou un prix plus élevé (€ à €€€) +- **Effort** : complexité de développement (S, M, L, XL) + +--- + +## 1 · Intelligence artificielle + +### 1.1 — Auto-fit : le texte qui s'adapte tout seul +Le dev envoie un titre de n'importe quelle longueur. L'API trouve automatiquement la taille de police optimale pour que le texte tienne parfaitement dans le format choisi, sans troncature, sans espace perdu. + +Techniquement : recherche binaire sur le titleSize, en s'appuyant sur le layout Pretext pour tester chaque taille en microsecondes. + +**Valeur :** ★★★ — Élimine le problème n°1 des OG images : le texte qui déborde +**Revenu :** €€ — Feature premium qui justifie le Starter +**Effort :** S — C'est un algorithme de 20 lignes grâce à Pretext + +--- + +### 1.2 — Smart crop : titre intelligent +L'API reçoit un titre trop long (ex: un titre d'article de 200 caractères). Au lieu de tronquer bêtement avec "…", elle utilise un LLM pour résumer le titre en une version courte qui tient dans l'image tout en gardant le sens. + +Exemple : +- Input : "How We Migrated Our Entire Infrastructure From AWS to Google Cloud Platform in 6 Months Without Any Downtime" +- Output : "AWS to GCP in 6 Months — Zero Downtime" + +**Valeur :** ★★★ — Les titres tronqués sont un problème universel +**Revenu :** €€€ — Feature premium, facturable en supplément (coût LLM) +**Effort :** M — Intégration API LLM + cache des résumés + +--- + +### 1.3 — Génération de description automatique +Le dev envoie juste une URL. L'API fetch la page, extrait le titre et la description (meta tags ou contenu), et génère l'image automatiquement. Zero config. + +``` +POST /render +{ "url": "https://myblog.com/my-article" } +``` + +**Valeur :** ★★★ — Réduit l'intégration à une seule ligne +**Revenu :** €€ — Feature qui accélère l'adoption +**Effort :** M — Scraping + extraction de meta tags + +--- + +### 1.4 — Style transfer : imiter un style visuel +Le dev uploade un screenshot d'une image OG qu'il aime. L'API analyse les couleurs, la disposition, et les polices, puis applique un style similaire à ses propres images. + +**Valeur :** ★★ — Cool mais niche +**Revenu :** €€ — Feature différenciante pour les agences +**Effort :** L — Analyse d'image + mapping de style + +--- + +## 2 · Analytics & insights + +### 2.1 — Dashboard d'usage visuel +Un dashboard web (og-engine.com/dashboard) qui montre : +- Nombre d'images générées par jour/semaine/mois +- Temps de rendu moyen +- Top 10 des contenus les plus générés +- Quota restant avec projection de fin de mois +- Alertes quand on approche de la limite + +**Valeur :** ★★★ — Les devs adorent les dashboards +**Revenu :** €€ — Rend le produit "sticky", augmente la rétention +**Effort :** M — Interface web + API d'analytics + +--- + +### 2.2 — A/B testing d'images OG +Le dev crée 2 variantes (titres différents, couleurs différentes, layouts différents). L'API sert alternativement la variante A ou B. Combiné avec un tracker de clics (UTM ou pixel), le dev peut voir quelle variante performe le mieux sur les réseaux sociaux. + +``` +POST /render +{ + "variants": [ + { "title": "10 Tips for Better Code", "style": { "accent": "#38ef7d" } }, + { "title": "Write Better Code Today", "style": { "accent": "#fb7185" } } + ], + "split": 50 +} +``` + +**Valeur :** ★★★ — Personne ne fait ça. Avantage compétitif énorme. +**Revenu :** €€€ — Feature enterprise, justifie le plan Scale +**Effort :** L — Routing A/B + tracking + reporting + +--- + +### 2.3 — Preview social multi-plateforme +Avant de publier, le dev voit un aperçu de comment son image apparaîtra sur Twitter, LinkedIn, Facebook, Slack, Discord, iMessage — chacun crop et affiche différemment. + +``` +GET /preview?url=https://myblog.com/article +→ Retourne un JSON avec les previews simulées pour chaque plateforme +``` + +**Valeur :** ★★★ — Problème réel que tout le monde a +**Revenu :** €€ — Feature gratuite pour l'acquisition, convertit vers payant +**Effort :** M — Simuler les viewports de chaque plateforme + +--- + +## 3 · Personnalisation avancée + +### 3.1 — Variables dynamiques dans les templates +Le template contient des placeholders ({{title}}, {{price}}, {{date}}) et le dev envoie juste les valeurs. Parfait pour l'e-commerce et l'email. + +``` +POST /render +{ + "template": "product-card", + "variables": { + "title": "Nike Air Max 90", + "price": "129€", + "badge": "-20%", + "image_url": "https://..." + } +} +``` + +**Valeur :** ★★★ — Transformer OG Engine en outil de templating visuel +**Revenu :** €€€ — Ouvre le segment e-commerce et email marketing +**Effort :** M — Parser de variables + injection dans le renderer + +--- + +### 3.2 — Éditeur visuel de templates (no-code) +Une interface web drag-and-drop pour créer des templates custom sans écrire de JSON. Le marketer (pas le dev) peut créer et modifier ses propres templates. + +**Valeur :** ★★★ — Ouvre le produit aux non-devs +**Revenu :** €€€ — Justifie un plan Enterprise à 299€+ +**Effort :** XL — C'est un mini design tool à construire + +--- + +### 3.3 — Thème de marque global +Le dev configure UNE FOIS son branding (couleurs, police, logo) et toutes les images générées respectent automatiquement la charte. Plus besoin de passer les styles à chaque appel. + +``` +POST /brand +{ + "accent": "#38ef7d", + "font": "Outfit", + "logo_url": "https://...", + "layout": "left" +} + +POST /render +{ "title": "Mon article" } +← L'image utilise automatiquement le branding +``` + +**Valeur :** ★★★ — Réduit la friction à chaque appel +**Revenu :** €€ — Feature sticky qui rend difficile de quitter +**Effort :** S — Une table de config + merge avec les defaults + +--- + +### 3.4 — Logo / watermark automatique +Inclure automatiquement le logo de l'entreprise sur chaque image générée. Position configurable (coin, en-tête, filigrane). + +**Valeur :** ★★ — Demande fréquente pour le branding +**Revenu :** € — Feature standard attendue +**Effort :** S — Charger et positionner une image sur le canvas + +--- + +## 4 · Automatisation & workflows + +### 4.1 — Intégration CMS : auto-génération au publish +Plugin pour les CMS populaires (WordPress, Ghost, Sanity, Contentful, Strapi, Notion). Quand un article est publié, l'image OG est générée automatiquement. + +**Valeur :** ★★★ — Zero effort pour l'utilisateur final +**Revenu :** €€€ — Chaque plugin est un canal d'acquisition +**Effort :** M par plugin — Webhook CMS → appel API + +--- + +### 4.2 — GitHub Action : OG images dans le CI/CD +Une GitHub Action qui génère les images OG au moment du deploy. Le dev ajoute un workflow YAML et c'est fini. + +```yaml +- uses: og-engine/generate@v1 + with: + api-key: ${{ secrets.OG_ENGINE_KEY }} + pages-dir: ./content + output-dir: ./public/og +``` + +**Valeur :** ★★★ — S'intègre dans le workflow existant des devs +**Revenu :** €€ — Augmente l'usage (= plus d'appels = upgrade) +**Effort :** M — GitHub Action + CLI tool + +--- + +### 4.3 — Scheduled regeneration +Programmer la régénération automatique des images à intervalles réguliers. Utile pour les contenus qui changent (prix, scores, données live). + +**Valeur :** ★★ — Niche mais haute valeur pour l'e-commerce +**Revenu :** €€ — Feature Pro/Scale +**Effort :** M — Cron scheduler + storage des configs + +--- + +### 4.4 — Zapier / Make integration +Connecter OG Engine à 5000+ apps via Zapier. Exemples : +- Nouveau post WordPress → générer OG image → uploader sur Cloudinary +- Nouvelle ligne Google Sheet → générer bannière → envoyer par email +- Nouveau produit Shopify → générer fiche visuelle + +**Valeur :** ★★★ — Ouvre les non-devs sans construire d'UI +**Revenu :** €€ — Augmente la base utilisateurs non-techniques +**Effort :** M — Créer une app Zapier + triggers/actions + +--- + +## 5 · Rendu avancé + +### 5.1 — QR code intégré +Ajouter un QR code automatique sur l'image, pointant vers l'URL du contenu. Utile pour les supports print et les présentations. + +``` +POST /render +{ + "title": "Mon événement", + "qr": { "url": "https://event.com/register", "position": "bottom-right" } +} +``` + +**Valeur :** ★★ — Niche mais différenciant +**Revenu :** € — Feature à inclure dans tous les plans payants +**Effort :** S — Librairie QR code + positionnement canvas + +--- + +### 5.2 — Animated OG images (GIF/WebP animé) +Générer des images OG animées : texte qui apparaît progressivement, fond en mouvement subtil, compteur animé. Certaines plateformes (Twitter, Slack) supportent les GIF dans les previews. + +**Valeur :** ★★ — Effet "wow" garanti +**Revenu :** €€ — Feature premium, haute valeur perçue +**Effort :** L — Frame-by-frame rendering + encodage GIF/WebP + +--- + +### 5.3 — Screenshot API +Au-delà des OG images : capturer un screenshot d'un composant web. Le dev envoie du HTML/CSS, l'API rend l'image. Concurrent direct de Puppeteer sur son terrain. + +**Valeur :** ★★★ — Élargit considérablement le marché adressable +**Revenu :** €€€ — Nouveau produit à part entière +**Effort :** XL — Nécessite un moteur de rendu HTML (ou headless léger) + +--- + +### 5.4 — PDF export +Même contenu, mais exporté en PDF au lieu de PNG. Utile pour les certificats, les factures, les rapports. + +**Valeur :** ★★ — Marché adjacent +**Revenu :** €€ — Feature Pro +**Effort :** M — Canvas to PDF + mise en page + +--- + +## 6 · Expérience développeur + +### 6.1 — Playground interactif dans les docs +Un playground web où le dev peut modifier le JSON de la requête et voir l'image se mettre à jour en temps réel. Comme le POC qu'on a construit, mais connecté à la vraie API. + +**Valeur :** ★★★ — Réduit drastiquement le time-to-first-call +**Revenu :** €€ — Convertit les visiteurs en utilisateurs +**Effort :** M — Adapter le POC existant + connecter à l'API + +--- + +### 6.2 — Logs et debug en temps réel +Un flux de logs en direct (type Vercel logs) montrant chaque appel API, son temps de rendu, et les éventuelles erreurs. Accessible dans le dashboard. + +**Valeur :** ★★ — Essentiel pour le debugging en production +**Revenu :** € — Feature attendue, pas différenciante +**Effort :** M — Streaming de logs + UI + +--- + +### 6.3 — Environnements staging/production +Deux clés API par compte : une pour le dev/staging (pas de limite, watermark "PREVIEW"), une pour la production. Le dev peut tester sans consommer son quota. + +**Valeur :** ★★★ — Résout une friction réelle +**Revenu :** €€ — Augmente la confiance → accélère l'adoption +**Effort :** S — Deux clés + flag "preview" dans le renderer + +--- + +### 6.4 — Webhook de notification +Notifier le dev quand une erreur récurrente est détectée, quand le quota approche, ou quand un rendu échoue. Via webhook, email, ou Slack. + +**Valeur :** ★★ — Proactivité appréciée +**Revenu :** € — Feature Pro +**Effort :** S — Event system + notification dispatch + +--- + +## 7 · Social & collaboration + +### 7.1 — Galerie de templates communautaire +Les utilisateurs peuvent publier leurs templates custom dans une galerie publique. Les autres peuvent les utiliser (fork) en un clic. Crée un effet réseau. + +**Valeur :** ★★ — Effet communauté + contenu gratuit +**Revenu :** €€ — Augmente l'adoption et la rétention +**Effort :** L — Galerie web + système de partage + +--- + +### 7.2 — Équipes et permissions +Plusieurs membres d'une équipe partagent le même quota et les mêmes templates, avec des rôles (admin, member, viewer). + +**Valeur :** ★★ — Nécessaire pour les entreprises +**Revenu :** €€€ — Débloquer un plan Team/Enterprise +**Effort :** L — Auth multi-user + permissions + +--- + +## Matrice de priorisation + +### Quick wins (haute valeur, faible effort) +| Feature | Valeur | Effort | +|---------|--------|--------| +| 1.1 Auto-fit | ★★★ | S | +| 3.3 Thème de marque | ★★★ | S | +| 3.4 Logo/watermark | ★★ | S | +| 5.1 QR code | ★★ | S | +| 6.3 Env staging/prod | ★★★ | S | + +### High impact (haute valeur, effort moyen) +| Feature | Valeur | Effort | +|---------|--------|--------| +| 1.3 Render depuis URL | ★★★ | M | +| 2.1 Dashboard usage | ★★★ | M | +| 2.3 Preview multi-plateforme | ★★★ | M | +| 3.1 Variables dynamiques | ★★★ | M | +| 4.1 Plugins CMS | ★★★ | M | +| 4.2 GitHub Action | ★★★ | M | +| 6.1 Playground | ★★★ | M | + +### Game changers (transformative, effort important) +| Feature | Valeur | Effort | +|---------|--------|--------| +| 1.2 Smart crop (LLM) | ★★★ | M | +| 2.2 A/B testing | ★★★ | L | +| 3.2 Éditeur no-code | ★★★ | XL | +| 4.4 Zapier/Make | ★★★ | M | +| 5.3 Screenshot API | ★★★ | XL | diff --git a/docs/analysis/USER-STORIES.md b/docs/analysis/USER-STORIES.md new file mode 100644 index 0000000..2e4c351 --- /dev/null +++ b/docs/analysis/USER-STORIES.md @@ -0,0 +1,474 @@ +# OG Engine — User Stories + +## Personas + +| Persona | Description | +|---------|-------------| +| **Dev** | Développeur intégrant l'API dans son produit (SaaS, blog, e-commerce) | +| **Marketer** | Marketeur utilisant l'API pour générer du contenu visuel à grande échelle | +| **Visitor** | Visiteur de la landing page, pas encore client | +| **Admin** | Administrateur de son compte OG Engine (facturation, clés) | + +## Priorités + +- **P0** — MVP, indispensable pour le lancement +- **P1** — Important, à livrer dans les 2 semaines post-lancement +- **P2** — Nice-to-have, itération post-lancement + +--- + +## Epic 1 — Inscription & Authentification + +### US-1.1 · Inscription gratuite sans carte bancaire +**Persona:** Visitor → Dev +**Priorité:** P0 + +> En tant que visiteur, je veux créer un compte gratuit avec juste mon email, afin de tester l'API sans friction ni engagement financier. + +**Critères d'acceptation:** +- Le visiteur envoie `POST /auth/register` avec `{ "email": "..." }` +- Le système génère une clé API au format `oge_sk_` + 32 caractères aléatoires +- La clé est envoyée par email en moins de 30 secondes +- L'email contient la clé, un exemple curl, et un lien vers la documentation +- Le plan est automatiquement "free" avec 500 appels/mois +- Si l'email existe déjà, renvoyer la clé existante (pas de doublon) + +--- + +### US-1.2 · Souscription payante via Stripe +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux souscrire à un plan payant via un lien de paiement Stripe, afin d'obtenir plus d'appels API et de fonctionnalités avancées. + +**Critères d'acceptation:** +- Chaque plan (Starter, Pro, Scale) a un Stripe Payment Link sur la landing page +- Après paiement, un webhook Stripe déclenche la création d'une clé API +- La clé est envoyée par email avec les détails du plan souscrit +- Si le dev a déjà un compte free, le plan est upgradé (pas de nouvelle clé) +- Le compteur d'appels est remis à zéro au moment de l'upgrade + +--- + +### US-1.3 · Changement de plan +**Persona:** Admin +**Priorité:** P1 + +> En tant qu'admin de mon compte, je veux changer de plan (upgrade ou downgrade), afin d'adapter mon abonnement à mon usage réel. + +**Critères d'acceptation:** +- Le portail Stripe (lien dans l'email de bienvenue) permet de changer de plan +- Le webhook `customer.subscription.updated` met à jour la limite d'appels +- L'upgrade est effectif immédiatement +- Le downgrade prend effet au prochain cycle de facturation +- Le dev reçoit un email de confirmation du changement + +--- + +### US-1.4 · Annulation d'abonnement +**Persona:** Admin +**Priorité:** P1 + +> En tant qu'admin, je veux pouvoir annuler mon abonnement, afin de ne plus être facturé. + +**Critères d'acceptation:** +- L'annulation se fait via le portail Stripe +- Le webhook `customer.subscription.deleted` rétrograde le plan à "free" +- La clé API reste active avec la limite free (500 appels/mois) +- Aucune donnée n'est supprimée + +--- + +## Epic 2 — Génération d'images (/render) + +### US-2.1 · Générer une image OG basique +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux envoyer un titre et une description à l'API et recevoir un PNG en retour, afin de générer automatiquement des images OG pour mon site. + +**Critères d'acceptation:** +- `POST /render` avec `{ "format": "og", "title": "...", "description": "..." }` retourne un PNG +- Le Content-Type de la réponse est `image/png` +- Les headers incluent `X-Render-Time-Ms`, `X-Title-Lines`, `X-Desc-Lines`, `X-Layout-Overflow` +- Le temps de rendu est inférieur à 10ms (hors réseau) +- L'image fait exactement 1200×630 pixels +- Le titre est tronqué avec "…" s'il dépasse 3 lignes +- La description est tronquée avec "…" si elle dépasse 4 lignes + +--- + +### US-2.2 · Choisir un format de sortie +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux choisir le format de l'image (OG, Twitter, Square, LinkedIn, Story), afin de générer des visuels adaptés à chaque plateforme. + +**Critères d'acceptation:** +- Le champ `format` accepte: `og` (1200×630), `twitter` (1200×675), `square` (1080×1080), `linkedin` (1200×627), `story` (1080×1920) +- Chaque format ajuste le nombre max de lignes titre/description +- Le format Story autorise 5 lignes de titre et 6 de description +- Si le format est absent ou invalide, retourner une erreur 400 + +--- + +### US-2.3 · Personnaliser le style +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux personnaliser la couleur d'accent, la police, la taille du texte et le layout, afin que les images générées correspondent à ma marque. + +**Critères d'acceptation:** +- `style.accent` accepte un code couleur hex (ex: "#38ef7d") +- `style.font` accepte le nom d'une police disponible (ex: "Outfit") +- `style.titleSize` accepte un nombre entre 28 et 72 +- `style.descSize` accepte un nombre entre 14 et 32 +- `style.layout` accepte: "left", "center", "bottom" +- Les valeurs par défaut sont appliquées si des champs sont omis +- `GET /health` retourne la liste des polices et layouts disponibles + +--- + +### US-2.4 · Ajouter un tag/catégorie +**Persona:** Dev / Marketer +**Priorité:** P1 + +> En tant qu'utilisateur, je veux ajouter un tag (ex: "Open Source", "Tutorial") qui apparaît comme un badge sur l'image, afin de catégoriser visuellement mon contenu. + +**Critères d'acceptation:** +- Le champ `tag` est optionnel +- S'il est présent, il s'affiche comme une pilule arrondie au-dessus du titre +- Le texte du tag est affiché en majuscules +- La couleur de la pilule est dérivée de la couleur d'accent + +--- + +### US-2.5 · Utiliser une image de fond +**Persona:** Dev / Marketer +**Priorité:** P1 + +> En tant qu'utilisateur, je veux uploader une image de fond pour mon visuel, afin de créer des images plus riches et personnalisées. + +**Critères d'acceptation:** +- L'endpoint accepte un upload multipart avec un champ `backgroundImage` +- Les formats acceptés sont: JPEG, PNG, WebP +- L'image est redimensionnée pour couvrir le canvas (object-fit: cover) +- Un overlay sombre est appliqué par défaut (opacité 0.65) +- Le champ `style.overlayOpacity` (0.2 à 0.9) permet de contrôler l'assombrissement +- La taille maximale du fichier est de 5MB +- Si l'image est invalide ou trop lourde, retourner une erreur 400 + +--- + +### US-2.6 · Choisir le format de sortie (PNG/WebP) +**Persona:** Dev +**Priorité:** P1 + +> En tant que développeur, je veux choisir entre PNG et WebP comme format de sortie, afin d'optimiser la taille des fichiers selon mes besoins. + +**Critères d'acceptation:** +- Le champ `output.format` accepte "png" (défaut) et "webp" +- Le champ `output.quality` (1-100) s'applique au WebP +- Le WebP est disponible à partir du plan Starter +- Si un utilisateur free demande du WebP, retourner une erreur 403 avec un message d'upgrade + +--- + +### US-2.7 · Choisir un template +**Persona:** Dev +**Priorité:** P1 + +> En tant que développeur, je veux choisir parmi plusieurs templates prédéfinis, afin de varier le style de mes visuels sans tout configurer manuellement. + +**Critères d'acceptation:** +- Le champ `template` accepte: "default", "social-card", "blog-hero", "email-banner" +- Chaque template a son propre agencement (positions du texte, décorations, style) +- Le template "default" est utilisé si le champ est absent +- `GET /health` retourne la liste des templates disponibles + +--- + +## Epic 3 — Validation de texte (/validate) + +### US-3.1 · Vérifier si un texte tient dans un format +**Persona:** Dev / Marketer +**Priorité:** P0 + +> En tant que développeur, je veux vérifier si mon titre et ma description tiennent dans un format donné sans générer d'image, afin de valider mes contenus rapidement et gratuitement. + +**Critères d'acceptation:** +- `POST /validate` avec `{ "format": "og", "title": "...", "description": "..." }` retourne un JSON +- La réponse contient: `fits` (boolean), `title.lines`, `title.overflow`, `description.lines`, `description.overflow` +- Le temps de calcul est retourné dans `computeTimeMs` +- L'endpoint est gratuit et illimité sur tous les plans (y compris free) +- L'endpoint n'incrémente PAS le compteur d'appels +- Le temps de réponse est inférieur à 5ms + +--- + +### US-3.2 · Valider avec des contraintes custom +**Persona:** Dev +**Priorité:** P1 + +> En tant que développeur, je veux spécifier un nombre max de lignes custom pour le titre et la description, afin de tester des contraintes spécifiques à mon UI. + +**Critères d'acceptation:** +- Les champs `maxTitleLines` et `maxDescLines` sont optionnels +- Par défaut: 3 lignes titre, 4 lignes description +- La réponse indique `overflow: true` si le texte dépasse la limite spécifiée +- On peut tester des polices et tailles différentes dans la même requête + +--- + +## Epic 4 — Batch Processing + +### US-4.1 · Générer plusieurs images en une requête +**Persona:** Dev / Marketer +**Priorité:** P1 + +> En tant que développeur, je veux envoyer une liste de contenus et recevoir toutes les images en une seule requête, afin de générer des visuels en masse efficacement. + +**Critères d'acceptation:** +- `POST /render/batch` accepte `{ "items": [...] }` avec jusqu'à 100 éléments +- Chaque élément a la même structure qu'une requête `/render` individuelle +- La réponse est un ZIP contenant les images nommées `0.png`, `1.png`, etc. +- Le temps total est retourné dans le header `X-Total-Render-Time-Ms` +- Chaque image du batch compte comme 1 appel API +- L'endpoint est réservé aux plans Pro et Scale +- Si un utilisateur Starter tente un batch, retourner 403 avec message d'upgrade + +--- + +### US-4.2 · Erreurs partielles dans un batch +**Persona:** Dev +**Priorité:** P2 + +> En tant que développeur, je veux que le batch continue même si un élément échoue, afin de ne pas perdre tout le traitement à cause d'une seule erreur. + +**Critères d'acceptation:** +- Si un élément du batch échoue (champ manquant, police invalide), les autres sont quand même générés +- Le ZIP contient un fichier `errors.json` listant les indices et messages d'erreur +- Le header `X-Batch-Errors` indique le nombre d'erreurs + +--- + +## Epic 5 — Usage & Limites + +### US-5.1 · Contrôle des limites d'appels +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux que l'API refuse mes appels quand j'ai atteint ma limite mensuelle, avec un message clair, afin de comprendre pourquoi et comment upgrader. + +**Critères d'acceptation:** +- Quand `calls_used >= calls_limit`, retourner HTTP 429 +- Le body contient: `error`, `limit`, `used`, `plan`, `upgrade_url` +- Les headers contiennent: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` +- Le compteur se remet à zéro au début de chaque cycle de facturation + +--- + +### US-5.2 · Consulter mon usage +**Persona:** Dev / Admin +**Priorité:** P1 + +> En tant qu'admin, je veux consulter mon usage actuel (appels consommés, limite, date de reset), afin de suivre ma consommation. + +**Critères d'acceptation:** +- `GET /usage` retourne: `plan`, `calls_used`, `calls_limit`, `period_start`, `period_end`, `days_remaining` +- L'endpoint nécessite une clé API valide +- L'endpoint ne compte PAS comme un appel API + +--- + +### US-5.3 · Reset mensuel automatique +**Persona:** Système +**Priorité:** P0 + +> En tant que système, je dois remettre le compteur d'appels à zéro à chaque nouveau cycle de facturation, afin que les utilisateurs récupèrent leur quota. + +**Critères d'acceptation:** +- Le webhook Stripe `invoice.paid` déclenche le reset +- `calls_used` est remis à 0 +- `period_start` est mis à jour avec la date courante +- Pour les comptes free (pas de webhook Stripe), un cron mensuel reset le compteur + +--- + +## Epic 6 — Gestion des erreurs + +### US-6.1 · Erreurs structurées et actionnables +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux que les erreurs de l'API soient structurées et claires, afin de pouvoir les traiter programmatiquement. + +**Critères d'acceptation:** +- Toutes les erreurs retournent du JSON: `{ "error": "code", "message": "description lisible", "details": {...} }` +- Codes d'erreur: `invalid_request`, `missing_field`, `invalid_font`, `invalid_format`, `unauthorized`, `rate_limited`, `plan_required`, `server_error` +- Les erreurs de validation listent tous les champs invalides (pas juste le premier) +- Les erreurs 4xx incluent un lien vers la documentation pertinente + +--- + +### US-6.2 · Clé API invalide ou manquante +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux un message d'erreur clair si ma clé API est manquante ou invalide, afin de pouvoir corriger rapidement. + +**Critères d'acceptation:** +- Pas de header Authorization → 401 `{ "error": "unauthorized", "message": "Missing API key. Include 'Authorization: Bearer oge_sk_...' header." }` +- Clé invalide → 401 `{ "error": "unauthorized", "message": "Invalid API key." }` +- Clé désactivée → 401 `{ "error": "unauthorized", "message": "API key has been deactivated." }` + +--- + +## Epic 7 — Health & Discovery + +### US-7.1 · Endpoint de santé +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux un endpoint de santé qui me donne les capacités de l'API, afin de découvrir les polices, formats et templates disponibles. + +**Critères d'acceptation:** +- `GET /health` retourne: `status`, `version`, `fonts[]`, `formats[]`, `templates[]` +- L'endpoint est public (pas de clé API requise) +- Le temps de réponse est inférieur à 50ms + +--- + +## Epic 8 — Landing Page & Conversion + +### US-8.1 · Page de vente avec pricing +**Persona:** Visitor +**Priorité:** P0 + +> En tant que visiteur, je veux comprendre le produit, voir les prix et m'inscrire en moins de 2 minutes, afin de commencer à utiliser l'API rapidement. + +**Critères d'acceptation:** +- La page contient: hero, stats, exemples de code, comparaison Puppeteer, pricing, FAQ, CTA +- Chaque bouton de pricing renvoie vers un Stripe Payment Link +- Le bouton "Free" renvoie vers un formulaire d'inscription par email +- La page est responsive (mobile-first) +- Le temps de chargement est inférieur à 2 secondes + +--- + +### US-8.2 · Démo interactive sur la landing page +**Persona:** Visitor +**Priorité:** P2 + +> En tant que visiteur, je veux tester le rendu en live sur la landing page, afin de voir la qualité avant de m'inscrire. + +**Critères d'acceptation:** +- Une section "Try it" permet de saisir un titre et une description +- Le rendu est calculé côté client (Canvas) en temps réel +- Un bouton "Generate via API" montre la requête curl correspondante +- Le visuel se met à jour à chaque frappe + +--- + +## Epic 9 — SDK & Documentation + +### US-9.1 · SDK TypeScript +**Persona:** Dev +**Priorité:** P1 + +> En tant que développeur, je veux un SDK TypeScript officiel, afin d'intégrer l'API sans écrire de fetch manuellement. + +**Critères d'acceptation:** +- Package npm: `og-engine-sdk` +- Méthodes: `render()`, `validate()`, `batchRender()`, `usage()` +- Types TypeScript pour toutes les requêtes et réponses +- Gestion automatique de l'authentification (clé passée au constructeur) +- Retry automatique sur erreur 5xx (1 retry, backoff 500ms) +- README avec exemples + +--- + +### US-9.2 · Documentation OpenAPI +**Persona:** Dev +**Priorité:** P1 + +> En tant que développeur, je veux une documentation OpenAPI/Swagger de l'API, afin de comprendre tous les endpoints et paramètres disponibles. + +**Critères d'acceptation:** +- Spec OpenAPI 3.1 disponible à `/docs/openapi.json` +- Interface Swagger UI disponible à `/docs` +- Chaque endpoint documenté avec exemples de requête et réponse +- Les codes d'erreur sont documentés + +--- + +## Epic 10 — Fonctionnalités avancées + +### US-10.1 · Templates custom (JSON DSL) +**Persona:** Dev +**Priorité:** P2 (Plan Scale uniquement) + +> En tant que développeur Scale, je veux définir mes propres templates en JSON, afin de créer des visuels totalement sur mesure. + +**Critères d'acceptation:** +- `POST /templates` crée un template custom avec un nom et une définition JSON +- La définition JSON spécifie les zones de texte (position, taille, police, couleur), les éléments décoratifs et les contraintes +- Le template est référençable dans `/render` via son nom +- Maximum 10 templates custom par compte +- Réservé au plan Scale + +--- + +### US-10.2 · Webhook de régénération +**Persona:** Dev +**Priorité:** P2 (Plan Pro+) + +> En tant que développeur, je veux configurer un webhook qui régénère automatiquement mes images quand mon contenu change, afin de garder mes visuels à jour sans intervention manuelle. + +**Critères d'acceptation:** +- `POST /webhooks` configure une URL de callback +- Quand le dev appelle `/render` avec un `content_id`, l'image est associée à cet ID +- Un appel à `POST /regenerate` avec le `content_id` et le nouveau contenu régénère l'image +- Le webhook notifie l'URL configurée avec l'URL de la nouvelle image +- Réservé aux plans Pro et Scale + +--- + +### US-10.3 · Cache CDN +**Persona:** Dev +**Priorité:** P2 (Plan Pro+) + +> En tant que développeur, je veux que mes images générées soient servies depuis un CDN, afin d'avoir des temps de réponse ultra-rapides pour les utilisateurs finaux. + +**Critères d'acceptation:** +- Chaque image générée est cachée avec un hash du contenu +- Si le même contenu est demandé à nouveau, l'image est servie depuis le cache +- Header `X-Cache: HIT` ou `X-Cache: MISS` dans la réponse +- Le cache expire après 7 jours ou sur régénération manuelle +- Le cache est inclus dans les plans Pro et Scale + +--- + +### US-10.4 · AI text fitting +**Persona:** Dev / Marketer +**Priorité:** P2 + +> En tant qu'utilisateur, je veux que l'API ajuste automatiquement la taille du texte pour que tout rentre dans le format choisi, afin de ne jamais avoir de texte tronqué. + +**Critères d'acceptation:** +- Le champ `style.autoFit: true` active l'ajustement automatique +- L'algorithme réduit progressivement `titleSize` jusqu'à ce que le titre tienne en max N lignes +- La taille minimale est 24px pour le titre, 12px pour la description +- La réponse indique la taille finale utilisée dans les headers + +--- + +## Résumé par priorité + +| Priorité | Stories | Epic | +|----------|---------|------| +| **P0** | US-1.1, 1.2, 2.1, 2.2, 2.3, 3.1, 5.1, 5.3, 6.1, 6.2, 7.1, 8.1 | MVP — 12 stories | +| **P1** | US-1.3, 1.4, 2.4, 2.5, 2.6, 2.7, 3.2, 4.1, 5.2, 9.1, 9.2 | Post-launch — 11 stories | +| **P2** | US-4.2, 8.2, 10.1, 10.2, 10.3, 10.4 | Itération — 6 stories | + +**Total: 29 user stories across 10 epics.** diff --git a/docs/analysis/landing-page.jsx b/docs/analysis/landing-page.jsx new file mode 100644 index 0000000..e2281d9 --- /dev/null +++ b/docs/analysis/landing-page.jsx @@ -0,0 +1,311 @@ +import { useState, useEffect, useRef } from "react"; + +const ACCENT = "#38ef7d"; + +function Counter({ target, suffix, duration = 1200 }) { + const [val, setVal] = useState(0); + const ref = useRef(null); + useEffect(() => { + const obs = new IntersectionObserver(([e]) => { + if (e.isIntersecting) { + const start = performance.now(); + const tick = () => { + const p = Math.min(1, (performance.now() - start) / duration); + setVal(Math.round(target * (1 - Math.pow(1 - p, 3)))); + if (p < 1) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + obs.disconnect(); + } + }, { threshold: 0.3 }); + if (ref.current) obs.observe(ref.current); + return () => obs.disconnect(); + }, [target, duration]); + return {val.toLocaleString()}{suffix}; +} + +function Section({ children, id, style = {} }) { + return
{children}
; +} +function Label({ children }) { + return
{children}
; +} +function H2({ children }) { + return

{children}

; +} + +function Code({ children, lang }) { + return ( +
+ {lang &&
{lang}
} +
{children}
+
+ ); +} + +function PricingCard({ name, price, calls, features, cta, popular, color }) { + return ( +
+
+ {popular &&
POPULAIRE
} +
{name}
+
+ {price} + /mois +
+
{calls}
+ {features.map((f, i) => ( +
+ {f} +
+ ))} + +
+
+ ); +} + +function FAQ({ q, a }) { + const [open, setOpen] = useState(false); + return ( +
+ + {open &&
{a}
} +
+ ); +} + +const CURL_EXAMPLE = `curl -X POST https://api.og-engine.com/render \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "format": "og", + "title": "Mon article de blog", + "description": "Une description captivante.", + "author": "Mon Entreprise", + "style": { + "accent": "#38ef7d", + "font": "Outfit" + } + }' --output og-image.png`; + +const RESPONSE_EXAMPLE = `HTTP/1.1 200 OK +Content-Type: image/png +X-Render-Time-Ms: 2.34 +X-Title-Lines: 1 +X-Layout-Overflow: false + +[binary PNG — 42kb]`; + +const JS_EXAMPLE = `const og = new OGEngine("YOUR_API_KEY") + +const image = await og.render({ + format: "og", + title: post.title, + description: post.excerpt, + author: post.author, + style: { accent: "#38ef7d", font: "Outfit" } +}) + +await Bun.write("og.png", image)`; + +const NEXTJS_EXAMPLE = `// app/api/og/[slug]/route.ts +export async function GET(req, { params }) { + const post = await getPost(params.slug) + + const res = await fetch( + "https://api.og-engine.com/render", + { + method: "POST", + headers: { + "Authorization": "Bearer " + API_KEY, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + format: "og", + title: post.title, + description: post.excerpt + }) + } + ) + + return new Response(res.body, { + headers: { "Content-Type": "image/png" } + }) +}`; + +export default function App() { + return ( +
+
+ + {/* HERO */} +
+
+ + API disponible +
+

+ Générez des images
sans navigateur. +

+

+ Une API qui remplace Puppeteer pour créer des images OG, bannières et visuels dynamiques. + 500x plus rapide. Zéro Chrome. +

+
+ Obtenir ma clé API + Voir la démo → +
+
+ + {/* STATS */} +
+ {[{ val: 500, suffix: "x", label: "plus rapide" }, { val: 2, suffix: "ms", label: "par image" }, { val: 0, suffix: "", label: "navigateur" }].map((s, i) => ( +
+
+
{s.label}
+
+ ))} +
+ + {/* HOW IT WORKS */} +
+ +

Un POST, une image.

+

+ Envoyez votre contenu en JSON, recevez un PNG en retour. Pas de Chrome, pas de Puppeteer. +

+ {CURL_EXAMPLE} + {RESPONSE_EXAMPLE} +
+ + {/* USE CASES */} +
+ +

Remplacez votre infra.

+
+ {[ + { icon: "🔗", title: "Images OG / Social Cards", desc: "Générez une miniature unique pour chaque page. SEO et partage optimisés." }, + { icon: "📧", title: "Bannières email dynamiques", desc: "Images personnalisées par destinataire. Prénom, offre, date — à la volée." }, + { icon: "🛒", title: "E-commerce", desc: "Visuels produits avec prix et badges promo. Le texte s'adapte toujours." }, + { icon: "✅", title: "Validation de texte", desc: "Endpoint /validate : vérifiez si votre copie tient dans un format. Gratuit et illimité." }, + ].map((c, i) => ( +
+
{c.icon}
+
{c.title}
+
{c.desc}
+
+ ))} +
+
+ + {/* COMPARISON */} +
+ +

Puppeteer vs OG Engine

+
+ {[ + ["", "Puppeteer", "OG Engine"], + ["Rendu", "~850ms", "~2ms"], + ["Mémoire", "300-500MB", "~10MB"], + ["Concurrence", "5-10/inst.", "500+/inst."], + ["Cold start", "2-5 sec", "50ms"], + ["Infra", "Chrome+Xvfb", "Node.js"], + ["Coût", "€€€", "€"], + ].map((row, i) => ( +
+ {row.map((cell, j) => ( +
0 ? ACCENT : j === 1 && i > 0 ? "#ef4444" : undefined, fontWeight: j === 0 ? 600 : 400, background: j === 2 && i > 0 ? `${ACCENT}05` : "transparent" }}>{cell}
+ ))} +
+ ))} +
+
+ + {/* PRICING */} +
+ +

Simple. Prévisible.

+

+ Pas de frais cachés. Pas d'engagement. +

+
+ + + + +
+
+ Besoin de plus ? Contactez-nous +
L'endpoint /validate est gratuit et illimité. +
+
+ + {/* INTEGRATION */} +
+ +

5 minutes pour intégrer.

+ {JS_EXAMPLE} + {NEXTJS_EXAMPLE} +
+ + {/* FAQ */} +
+ +

FAQ

+
+ + + + + + +
+
+ + {/* CTA */} +
+

+ Prêt à tuer Puppeteer ? +

+

500 appels gratuits. Aucune carte requise.

+ + Créer mon compte gratuitement + +
+ +
+ OG Engine · Propulsé par Pretext · 2026 +
+ + +
+ ); +} diff --git a/docs/analysis/og-engine.jsx b/docs/analysis/og-engine.jsx new file mode 100644 index 0000000..da6da72 --- /dev/null +++ b/docs/analysis/og-engine.jsx @@ -0,0 +1,568 @@ +import { useState, useEffect, useRef, useCallback } from "react"; + +// ─── Text measurement (Pretext principle) ──────────────────────────────────── +let _ctx = null; +function getCtx() { + if (!_ctx) { const c = document.createElement("canvas"); _ctx = c.getContext("2d"); } + return _ctx; +} +function measureLines(text, font, maxW) { + if (!text || maxW <= 0) return []; + const ctx = getCtx(); ctx.font = font; + const lines = []; + for (const para of text.split("\n")) { + if (!para.trim()) { lines.push({ text: "", w: 0 }); continue; } + let cur = "", curW = 0; + for (const word of para.split(/\s+/)) { + if (!word) continue; + const ww = ctx.measureText(word).width; + const sp = cur ? ctx.measureText(" ").width : 0; + if (curW + sp + ww > maxW && cur) { lines.push({ text: cur, w: curW }); cur = word; curW = ww; } + else { cur += (cur ? " " : "") + word; curW += sp + ww; } + } + if (cur) lines.push({ text: cur, w: curW }); + } + return lines; +} +function tw(text, font) { const ctx = getCtx(); ctx.font = font; return ctx.measureText(text).width; } + +// ─── Config ────────────────────────────────────────────────────────────────── +const FORMATS = { + og: { w: 1200, h: 630, label: "OG", ratio: "1200×630" }, + twitter: { w: 1200, h: 675, label: "Twitter", ratio: "1200×675" }, + square: { w: 1080, h: 1080, label: "Square", ratio: "1080²" }, + linkedin: { w: 1200, h: 627, label: "LinkedIn", ratio: "1200×627" }, + story: { w: 1080, h: 1920, label: "Story", ratio: "1080×1920" }, +}; +const ACCENTS = [ + "#38ef7d","#67e8f9","#c4b5fd","#fbbf24","#fb7185","#fb923c","#e2e8f0","#a3e635", +]; +const GRADIENTS = [ + { name: "Void", stops: ["#0c0f1a","#080a12"] }, + { name: "Deep Sea", stops: ["#0a1628","#061220"] }, + { name: "Ember", stops: ["#1a0a0a","#120808"] }, + { name: "Forest", stops: ["#0a1a10","#061208"] }, + { name: "Plum", stops: ["#150a1a","#0e0812"] }, + { name: "Slate", stops: ["#12141a","#0a0c10"] }, +]; +const FONTS = [ + { name: "System", family: "system-ui, -apple-system, sans-serif", google: null }, + { name: "Outfit", family: "'Outfit', sans-serif", google: "Outfit:wght@400;700;800" }, + { name: "Playfair", family: "'Playfair Display', serif", google: "Playfair+Display:wght@400;700;800" }, + { name: "Sora", family: "'Sora', sans-serif", google: "Sora:wght@400;600;800" }, + { name: "Space Grotesk", family: "'Space Grotesk', sans-serif", google: "Space+Grotesk:wght@400;600;700" }, + { name: "DM Serif", family: "'DM Serif Display', serif", google: "DM+Serif+Display" }, + { name: "Bricolage", family: "'Bricolage Grotesque', sans-serif", google: "Bricolage+Grotesque:wght@400;700;800" }, + { name: "Crimson Pro", family: "'Crimson Pro', serif", google: "Crimson+Pro:wght@400;600;800" }, +]; +const LAYOUTS = { left: "Left", center: "Center", bottom: "Bottom" }; + +// ─── Load Google Fonts ─────────────────────────────────────────────────────── +const loadedFonts = new Set(); +function loadGFont(entry) { + if (!entry.google || loadedFonts.has(entry.name)) return; + loadedFonts.add(entry.name); + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = `https://fonts.googleapis.com/css2?family=${entry.google}&display=swap`; + document.head.appendChild(link); +} + +// ─── Canvas Render ─────────────────────────────────────────────────────────── +function renderCard(canvas, o) { + const { title, desc, author, tag, format, accent, layout, titleSize, descSize, + fontEntry, gradient, bgImage, overlayOpacity } = o; + const { w: W, h: H } = FORMATS[format]; + canvas.width = W; canvas.height = H; + const ctx = canvas.getContext("2d"); + const s = Math.max(W, H) / 1200; + const ff = fontEntry.family; + + // ── BG image or gradient + if (bgImage) { + ctx.drawImage(bgImage, 0, 0, W, H); + ctx.fillStyle = `rgba(0,0,0,${overlayOpacity})`; + ctx.fillRect(0, 0, W, H); + } else { + const bg = ctx.createLinearGradient(0, 0, W * 0.3, H); + bg.addColorStop(0, gradient.stops[0]); + bg.addColorStop(1, gradient.stops[1]); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + } + + // ── Grid + ctx.strokeStyle = accent + "05"; ctx.lineWidth = 1; + const gs = 50 * s; + for (let x = 0; x < W; x += gs) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); } + for (let y = 0; y < H; y += gs) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); } + + // ── Glow + const g1 = ctx.createRadialGradient(W * 0.15, H * 0.8, 0, W * 0.15, H * 0.8, W * 0.35); + g1.addColorStop(0, accent + "10"); g1.addColorStop(1, "transparent"); + ctx.fillStyle = g1; ctx.fillRect(0, 0, W, H); + + // ── Layout + const px = Math.round(64 * s); + const cW = W - px * 2; + const isC = layout === "center", isB = layout === "bottom"; + + // tag + const tagFont = `600 ${Math.round(14 * s)}px ${ff}`; + let tagH = 0; + if (tag) { tagH = 28 * s + 16 * s; } + + // title + const tFont = `800 ${Math.round(titleSize * s)}px ${ff}`; + const tLH = Math.round(titleSize * 1.2 * s); + const tLines = measureLines(title || "Untitled", tFont, cW); + const mxT = format === "story" ? 5 : 3; + const vT = tLines.slice(0, mxT); + + // desc + const dFont = `400 ${Math.round(descSize * s)}px ${ff}`; + const dLH = Math.round(descSize * 1.55 * s); + const dLines = measureLines(desc || "", dFont, cW); + const mxD = format === "story" ? 6 : 4; + const vD = dLines.slice(0, mxD); + + const aFont = `700 ${Math.round(18 * s)}px ${ff}`; + const aH = 24 * s; + const g2v = 16 * s, g3v = 20 * s, g4v = 28 * s; + const totalH = tagH + vT.length * tLH + g3v + vD.length * dLH + g4v + aH; + + let y = isB ? H - px - totalH : isC ? (H - totalH) / 2 : Math.round(px * 1.2); + const align = isC ? "center" : "left"; + const xP = isC ? W / 2 : px; + ctx.textAlign = align; ctx.textBaseline = "top"; + + // accent bar + if (!isC && !bgImage) { + ctx.fillStyle = accent; + ctx.fillRect(px, y, 4 * s, Math.min(vT.length * tLH + tagH, 80 * s)); + } + + // tag pill + if (tag) { + ctx.font = tagFont; + const tgW = tw(tag.toUpperCase(), tagFont); + const pW = tgW + 24 * s, pH = 28 * s; + const pX = isC ? (W - pW) / 2 : px; + ctx.fillStyle = accent + "18"; + ctx.beginPath(); ctx.roundRect(pX, y, pW, pH, pH / 2); ctx.fill(); + ctx.fillStyle = accent; ctx.font = tagFont; + ctx.textAlign = "center"; + ctx.fillText(tag.toUpperCase(), pX + pW / 2, y + pH / 2 - 7 * s); + ctx.textAlign = align; + y += tagH; + } + + // title + ctx.fillStyle = "#f1f5f9"; ctx.font = tFont; + for (let i = 0; i < vT.length; i++) { + let t = vT[i].text; + if (i === vT.length - 1 && tLines.length > mxT) t += "…"; + ctx.fillText(t, xP, y); y += tLH; + } + y += g3v; + + // desc + ctx.fillStyle = bgImage ? "#d1d5db" : "#94a3b8"; ctx.font = dFont; + for (let i = 0; i < vD.length; i++) { + let t = vD[i].text; + if (i === vD.length - 1 && dLines.length > mxD) t += "…"; + ctx.fillText(t, xP, y); y += dLH; + } + y += g4v; + + // author + ctx.fillStyle = accent; ctx.font = aFont; + ctx.fillText(author || "", xP, y); + + // badge + ctx.fillStyle = accent + "33"; + ctx.font = `500 ${Math.round(12 * s)}px ui-monospace, monospace`; + ctx.textAlign = "right"; + ctx.fillText("⚡ no browser required", W - px, H - px * 0.7); + ctx.textAlign = "left"; + + // frame + ctx.strokeStyle = accent + "12"; ctx.lineWidth = 1; + const fr = 24 * s; ctx.strokeRect(fr, fr, W - fr * 2, H - fr * 2); + + return { tTotal: tLines.length, tVis: vT.length, dTotal: dLines.length, dVis: vD.length }; +} + +// ─── Small UI ──────────────────────────────────────────────────────────────── +const Chip = ({ label, active, color, onClick, small }) => ( + +); + +const Dot = ({ hex, active, onClick }) => ( + +); + +const Field = ({ label, value, onChange, multiline, accent }) => { + const s = { + width: "100%", padding: "9px 11px", borderRadius: 8, + border: "1px solid rgba(255,255,255,0.08)", background: "rgba(255,255,255,0.03)", + color: "#e2e8f0", fontSize: 13, fontFamily: "inherit", outline: "none", lineHeight: 1.5, + }; + return ( +
+ + {multiline + ? +
+ + +
+ ` +} +``` + +- [ ] **Step 2: Create webhooks view** + +Create `src/dashboard/views/webhooks.ts`: + +```typescript +import { escapeHtml } from '../../utils/html' +import type { WebhookRecord } from '../../db/index' + +export function webhooksView(webhooks: WebhookRecord[]): string { + const listHtml = webhooks.length === 0 + ? '

No webhooks

Create a webhook to trigger renders automatically.

' + : webhooks.map((w) => ` + + ${escapeHtml(w.url)} + ${w.active ? 'Active' : 'Inactive'} + ${new Date(w.created_at).toLocaleString()} + + + + + + + `).join('') + + return ` + + +
+ + ${listHtml}
URLStatusCreatedActions
+
+ +
+

Create Webhook

+
+
+ + +
+ +
+
+ ` +} +``` + +- [ ] **Step 3: Create settings view** + +Create `src/dashboard/views/settings.ts`: + +```typescript +import { escapeHtml } from '../../utils/html' +import type { UserRecord } from '../../types' + +export function settingsView(user: UserRecord): string { + return ` + + +
+
+
+ +
${escapeHtml(user.email)}
+ To change your email, log in with a different address. +
+ +
+ +
${escapeHtml(user.id)}
+
+ +
+ +
${new Date(user.created_at).toLocaleDateString()}
+
+
+
+ +
+

Danger Zone

+
+

Permanently delete your account, all API keys, and render history. This cannot be undone.

+ +
+
+ ` +} +``` + +- [ ] **Step 4: Add routes for all three** + +In `src/dashboard/routes.ts`, add: + +```typescript +import { listCustomTemplatesByUser, createCustomTemplate, deleteCustomTemplate } from '../db/index' +import { listWebhooksByUser, createWebhook, deleteWebhook } from '../db/index' +import { templatesView } from './views/templates' +import { webhooksView } from './views/webhooks' +import { settingsView } from './views/settings' + +// Templates +dashboardRoutes.get('/dashboard/templates', (c) => { + const user = c.get('user' as never) as UserRecord + const templates = user.plan === 'scale' ? listCustomTemplatesByUser(user.id) : [] + return respond(c, 'Templates', '/dashboard/templates', templatesView(user, templates)) +}) + +dashboardRoutes.post('/dashboard/templates', async (c) => { + const user = c.get('user' as never) as UserRecord + if (user.plan !== 'scale') return c.text('Upgrade required', 402) + const form = await c.req.parseBody() + const name = form.name as string + const definition = form.definition as string + try { + const parsed = JSON.parse(definition) + createCustomTemplate(user.id, name, parsed) + } catch { + return c.html('
Invalid JSON
', 400) + } + const templates = listCustomTemplates(user.id) + return c.html(templatesView(user, templates)) +}) + +dashboardRoutes.delete('/dashboard/templates/:id', (c) => { + const user = c.get('user' as never) as UserRecord + const id = c.req.param('id') + deleteCustomTemplate(id) + return c.text('') +}) + +// Webhooks +dashboardRoutes.get('/dashboard/webhooks', (c) => { + const user = c.get('user' as never) as UserRecord + const webhooks = listWebhooksByUser(user.id) + return respond(c, 'Webhooks', '/dashboard/webhooks', webhooksView(webhooks)) +}) + +dashboardRoutes.post('/dashboard/webhooks', async (c) => { + const user = c.get('user' as never) as UserRecord + const form = await c.req.parseBody() + const url = form.url as string + createWebhook(user.id, url, {}) + const webhooks = listWebhooksByUser(user.id) + return c.html(webhooksView(webhooks)) +}) + +dashboardRoutes.delete('/dashboard/webhooks/:id', (c) => { + const id = c.req.param('id') + deleteWebhook(id) + return c.text('') +}) + +dashboardRoutes.post('/dashboard/webhooks/:id/test', async (c) => { + const id = c.req.param('id') + try { + const webhook = (await import('../db/index')).findWebhookById(id) + if (!webhook) return c.html('Not found') + const res = await fetch(webhook.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ test: true, timestamp: new Date().toISOString() }), + }) + return c.html(`${res.status}`) + } catch { + return c.html('Failed') + } +}) + +// Settings +dashboardRoutes.get('/dashboard/settings', (c) => { + const user = c.get('user' as never) as UserRecord + return respond(c, 'Settings', '/dashboard/settings', settingsView(user)) +}) + +dashboardRoutes.delete('/dashboard/settings/account', async (c) => { + const user = c.get('user' as never) as UserRecord + // Cancel Stripe subscription if exists + if (user.stripe_subscription_id && process.env.STRIPE_SECRET_KEY) { + try { + const stripe = new (await import('stripe')).default(process.env.STRIPE_SECRET_KEY) + await stripe.subscriptions.cancel(user.stripe_subscription_id) + } catch { + // Best effort + } + } + // Deactivate user and all keys + getDb().prepare('UPDATE api_keys SET active = 0 WHERE user_id = ?').run(user.id) + getDb().prepare('UPDATE users SET active = 0 WHERE id = ?').run(user.id) + getDb().prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id) + // Clear cookie and redirect + const { clearSessionCookie } = await import('../auth/middleware') + clearSessionCookie(c) + return c.redirect('/auth/login', 302) +}) +``` + +- [ ] **Step 5: Note on listCustomTemplates/listWebhooks** + +The existing `listCustomTemplates(apiKeyId)` and `listWebhooks(apiKeyId)` take an `apiKeyId`. These need to be updated to accept `userId` instead, since a user may have multiple keys. Update the WHERE clause from `api_key_id = ?` to `api_key_id IN (SELECT id FROM api_keys WHERE user_id = ?)` — or better, add a `user_id` column to `custom_templates` and `webhooks` tables during the migration task. + +For now, the simplest approach is to query by looking up all the user's key IDs first: + +```typescript +export function listCustomTemplatesByUser(userId: string): CustomTemplateRecord[] { + return getDb().prepare(` + SELECT ct.* FROM custom_templates ct + JOIN api_keys ak ON ct.api_key_id = ak.id + WHERE ak.user_id = ? + ORDER BY ct.updated_at DESC + `).all(userId) as CustomTemplateRecord[] +} + +export function listWebhooksByUser(userId: string): WebhookRecord[] { + return getDb().prepare(` + SELECT w.* FROM webhooks w + JOIN api_keys ak ON w.api_key_id = ak.id + WHERE ak.user_id = ? + ORDER BY w.created_at DESC + `).all(userId) as WebhookRecord[] +} +``` + +Update the route handlers to use these new functions. + +- [ ] **Step 6: Run full test suite** + +Run: `bun run test` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/dashboard/views/ src/dashboard/routes.ts src/db/index.ts +git commit -m "feat(dashboard): add templates, webhooks, and settings views" +``` + +--- + +## Phase 4: OpenAPI / Swagger + +### Task 18: Verify Zod v4 compatibility and set up OpenAPI + +**Files:** +- Modify: `package.json` +- Create: `src/openapi/spec.ts` +- Create: `src/openapi/swagger.ts` +- Modify: `src/index.ts` +- Test: `tests/openapi.test.ts` + +- [ ] **Step 1: Test Zod v4 compatibility** + +Run: `bun add @hono/zod-openapi @hono/swagger-ui` + +Then create a quick test to check if it works with Zod v4: + +```typescript +// Quick check — run this in a temp file +import { z } from 'zod' +import { createRoute } from '@hono/zod-openapi' + +const testRoute = createRoute({ + method: 'get', + path: '/test', + responses: { + 200: { + description: 'OK', + content: { 'application/json': { schema: z.object({ ok: z.boolean() }) } }, + }, + }, +}) +console.log('Zod v4 + @hono/zod-openapi: compatible') +``` + +Run: `bun run /tmp/test-zod-compat.ts` + +If this fails, fall back to a static OpenAPI spec. See step 3b. + +- [ ] **Step 2: Write failing test** + +Create `tests/openapi.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Hono } from 'hono' + +describe('OpenAPI', () => { + let app: Hono + + beforeEach(async () => { + process.env.DATABASE_URL = ':memory:' + const { getDb, migrate } = await import('../src/db/index') + migrate(getDb()) + + // Use the full app for this test + const mod = await import('../src/index') + app = (mod as any).app ?? new Hono() + }) + + afterEach(() => { + const { closeDb } = require('../src/db/index') + closeDb() + delete process.env.DATABASE_URL + }) + + it('GET /openapi.json returns valid OpenAPI spec', async () => { + const res = await app.request('/openapi.json') + expect(res.status).toBe(200) + const spec = await res.json() + expect(spec.openapi).toMatch(/^3\./) + expect(spec.info.title).toBe('OG Engine API') + expect(spec.paths['/render']).toBeDefined() + expect(spec.paths['/validate']).toBeDefined() + expect(spec.paths['/health']).toBeDefined() + }) + + it('GET /docs returns Swagger UI HTML', async () => { + const res = await app.request('/docs') + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('swagger') + }) +}) +``` + +- [ ] **Step 3a: Implement with @hono/zod-openapi (if compatible)** + +Create `src/openapi/spec.ts`: + +```typescript +import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi' +import { renderSchema, validateSchema, batchSchema } from '../schemas/request' + +export function createOpenApiSpec() { + const spec = { + openapi: '3.1.0', + info: { + title: 'OG Engine API', + description: 'Server-side image generation API. Send JSON, get back PNG/WebP.', + version: '0.1.0', + }, + servers: [{ url: '/' }], + paths: { + '/render': { + post: { + summary: 'Generate an image', + description: 'Generate an OG image from text + configuration.', + tags: ['Render'], + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/RenderRequest' } } }, + }, + responses: { + 200: { description: 'Rendered image', content: { 'image/png': {} } }, + 400: { description: 'Invalid request' }, + 401: { description: 'Unauthorized' }, + 429: { description: 'Quota exceeded' }, + }, + }, + }, + '/validate': { + post: { + summary: 'Check if text fits', + description: 'Check if text fits a given layout without generating an image.', + tags: ['Validate'], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/ValidateRequest' } } }, + }, + responses: { + 200: { description: 'Validation result', content: { 'application/json': {} } }, + }, + }, + }, + '/render/from-url': { + post: { + summary: 'Render from URL', + description: 'Fetch OG tags from a URL and render a card automatically.', + tags: ['Render'], + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { 'application/json': { schema: { type: 'object', properties: { url: { type: 'string', format: 'uri' }, format: { type: 'string' }, style: { type: 'object' }, overrides: { type: 'object' } }, required: ['url'] } } }, + }, + responses: { 200: { description: 'Rendered image' } }, + }, + }, + '/render/batch': { + post: { + summary: 'Batch render', + description: 'Render multiple images in one request (Pro+ only).', + tags: ['Render'], + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/BatchRequest' } } }, + }, + responses: { 200: { description: 'ZIP archive of images' }, 402: { description: 'Plan upgrade required' } }, + }, + }, + '/health': { + get: { + summary: 'Health check', + description: 'Service discovery — available fonts, formats, templates.', + tags: ['System'], + responses: { 200: { description: 'Service status', content: { 'application/json': {} } } }, + }, + }, + '/auth/register': { + post: { + summary: 'Register for an API key', + description: 'Create a free API key. Returns the key immediately and sends it via email.', + tags: ['Auth'], + requestBody: { + required: true, + content: { 'application/json': { schema: { type: 'object', properties: { email: { type: 'string', format: 'email' } }, required: ['email'] } } }, + }, + responses: { 201: { description: 'API key created' }, 200: { description: 'Key already exists' } }, + }, + }, + '/usage': { + get: { + summary: 'Get usage stats', + description: 'View current quota usage and statistics.', + tags: ['Account'], + security: [{ bearerAuth: [] }], + responses: { 200: { description: 'Usage statistics' } }, + }, + }, + '/billing/portal': { + get: { + summary: 'Billing portal', + description: 'Get a link to the Stripe Customer Portal.', + tags: ['Account'], + security: [{ bearerAuth: [] }], + responses: { 200: { description: 'Portal URL' } }, + }, + }, + }, + components: { + securitySchemes: { + bearerAuth: { type: 'http', scheme: 'bearer', description: 'API key (oge_sk_...)' }, + }, + schemas: { + RenderRequest: { type: 'object', description: 'See /docs for full schema' }, + ValidateRequest: { type: 'object', description: 'See /docs for full schema' }, + BatchRequest: { type: 'object', description: 'See /docs for full schema' }, + }, + }, + } + return spec +} +``` + +Note: If `@hono/zod-openapi` works with Zod v4, convert this to use `createRoute()` with proper Zod schema references for auto-generation. If not, this static spec is the fallback. + +- [ ] **Step 3b: Create Swagger UI route** + +Create `src/openapi/swagger.ts`: + +```typescript +import { Hono } from 'hono' +import { createOpenApiSpec } from './spec' + +export const openapiRoutes = new Hono() + +openapiRoutes.get('/openapi.json', (c) => { + return c.json(createOpenApiSpec()) +}) + +openapiRoutes.get('/docs', (c) => { + return c.html(` + + + + OG Engine API — Swagger + + + +
+ + + +`) +}) +``` + +- [ ] **Step 4: Register in index.ts** + +In `src/index.ts`, add: + +```typescript +import { openapiRoutes } from './openapi/swagger' +app.route('/', openapiRoutes) +``` + +- [ ] **Step 5: Run test** + +Run: `bun run test -- tests/openapi.test.ts` +Expected: PASS + +- [ ] **Step 6: Run full test suite** + +Run: `bun run test` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/openapi/ tests/openapi.test.ts src/index.ts package.json bun.lockb +git commit -m "feat(openapi): add OpenAPI spec and Swagger UI at /docs" +``` + +--- + +## Phase 5: Integration & Polish + +### Task 19: End-to-end smoke test + +**Files:** +- Test: `tests/e2e.test.ts` + +- [ ] **Step 1: Write full flow test** + +Create `tests/e2e.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { getDb, migrate, findMagicLinkByToken, createMagicLinkToken } from '../src/db/index' + +describe('end-to-end: register → login → dashboard', () => { + beforeEach(() => { + process.env.DATABASE_URL = ':memory:' + process.env.AUTH_ENABLED = 'true' + migrate(getDb()) + }) + + afterEach(() => { + const { closeDb } = require('../src/db/index') + closeDb() + delete process.env.DATABASE_URL + }) + + it('full auth flow: register → magic link → dashboard → logout', async () => { + // Dynamically import to get a fresh app instance + const { authRoutes } = await import('../src/auth/routes') + const { dashboardRoutes } = await import('../src/dashboard/routes') + const { sessionMiddleware, csrfMiddleware } = await import('../src/auth/middleware') + const { Hono } = await import('hono') + + const app = new Hono() + app.route('/', authRoutes) + app.use('/dashboard/*', sessionMiddleware()) + app.use('/dashboard/*', csrfMiddleware()) + app.route('/', dashboardRoutes) + + // 1. Login page renders + const loginRes = await app.request('/auth/login') + expect(loginRes.status).toBe(200) + + // 2. Send magic link + const sendRes = await app.request('/auth/send-link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'e2e@example.com' }), + }) + expect(sendRes.status).toBe(200) + + // 3. Simulate clicking magic link (get token from DB since email is mocked) + const { createMagicLinkToken: _ } = await import('../src/auth/magic-link') + // The token was created via createMagicLinkToken in the route handler + // We need to find it — query the DB directly + const db = getDb() + const link = db.prepare('SELECT * FROM magic_links ORDER BY created_at DESC LIMIT 1').get() as any + expect(link).not.toBeNull() + + // We can't easily get the raw token from the DB (it's hashed) + // So let's create a fresh magic link for the test + const { token } = (await import('../src/auth/magic-link')).createMagicLinkToken('e2e@example.com') + + const verifyRes = await app.request(`/auth/verify?token=${token}`) + expect(verifyRes.status).toBe(302) + expect(verifyRes.headers.get('Location')).toBe('/dashboard') + const setCookie = verifyRes.headers.get('Set-Cookie')! + expect(setCookie).toContain('oge_session=') + + // Extract session token from cookie + const sessionToken = setCookie.match(/oge_session=([^;]+)/)![1] + + // 4. Dashboard loads with session + const dashRes = await app.request('/dashboard', { + headers: { Cookie: `oge_session=${sessionToken}` }, + }) + expect(dashRes.status).toBe(200) + const html = await dashRes.text() + expect(html).toContain('Overview') + expect(html).toContain('e2e@example.com') + + // 5. Logout + const logoutRes = await app.request('/auth/logout', { + method: 'POST', + headers: { Cookie: `oge_session=${sessionToken}` }, + }) + expect(logoutRes.status).toBe(302) + + // 6. Dashboard now redirects to login + const afterLogoutRes = await app.request('/dashboard', { + headers: { Cookie: `oge_session=${sessionToken}` }, + }) + expect(afterLogoutRes.status).toBe(302) + expect(afterLogoutRes.headers.get('Location')).toContain('/auth/login') + }) +}) +``` + +- [ ] **Step 2: Run test** + +Run: `bun run test -- tests/e2e.test.ts` +Expected: PASS + +- [ ] **Step 3: Run full test suite** + +Run: `bun run test` +Expected: ALL PASS + +- [ ] **Step 4: Commit** + +```bash +git add tests/e2e.test.ts +git commit -m "test: add end-to-end smoke test for auth → dashboard flow" +``` + +--- + +### Task 20: Final integration in index.ts and type-check + +**Files:** +- Modify: `src/index.ts` + +- [ ] **Step 1: Verify all routes are registered** + +Review `src/index.ts` and ensure these route groups are all registered: + +1. CORS middleware (existing) +2. Rate limiting (existing) +3. Auth middleware for API routes (existing) +4. Static file serving (`/static/*`) +5. Auth routes (`/auth/*`) +6. Session + CSRF middleware for dashboard (`/dashboard/*`) +7. Dashboard routes (`/dashboard/*`) +8. OpenAPI routes (`/openapi.json`, `/docs`) +9. Existing API routes (unchanged) + +- [ ] **Step 2: Export app for testing** + +Make sure `src/index.ts` exports the `app` instance for test use: + +```typescript +export { app } +``` + +- [ ] **Step 3: Run type-check** + +Run: `bun run type-check` +Expected: No errors + +- [ ] **Step 4: Run linter** + +Run: `bun run lint` +Fix any issues. + +- [ ] **Step 5: Run full test suite** + +Run: `bun run test` +Expected: ALL PASS + +- [ ] **Step 6: Final commit** + +```bash +git add . +git commit -m "feat: complete dashboard, auth, and swagger integration" +``` + +--- + +## Summary + +| Phase | Tasks | What it delivers | +|---|---|---| +| Phase 1: Database | Tasks 1-5 | Users table, per-user quotas, sessions, magic_links, render_history, migration script | +| Phase 2: Auth | Tasks 6-9 | Magic link flow, session middleware, CSRF protection, auth routes | +| Phase 3: Dashboard | Tasks 10-17 | htmx shell, all 8 dashboard sections, static assets | +| Phase 4: OpenAPI | Task 18 | OpenAPI spec at `/openapi.json`, Swagger UI at `/docs` | +| Phase 5: Integration | Tasks 19-20 | E2E test, final wiring, type-check | From ed7fd942fedc83d78abdc1e609d60e1b91d7e762 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 16 Apr 2026 23:33:20 +0200 Subject: [PATCH 184/189] feat(infra): add staging environment on Fly.io with CI deployment gate - Add fly.staging.toml for og-engine-staging (shared-cpu-1x, 256MB, cdg region) - Split CI deploy job: staging on dev push, production on v* tag push - Add DEPLOYMENT.md: staging bootstrap steps, branch protection rules, release workflow Co-Authored-By: Paperclip --- .github/workflows/ci.yml | 27 +++- DEPLOYMENT.md | 273 +++++++++++++++++++++++++++++++++++++++ fly.staging.toml | 58 +++++++++ 3 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 DEPLOYMENT.md create mode 100644 fly.staging.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee2b46a..f31f381 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: push: branches: [main, dev] + tags: ["v*"] pull_request: branches: [main, dev] @@ -87,18 +88,36 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - deploy: - name: Deploy to Fly.io + deploy-staging: + name: Deploy to Fly.io (Staging) runs-on: ubuntu-latest needs: check if: github.ref == 'refs/heads/dev' && github.event_name == 'push' + environment: staging steps: - uses: actions/checkout@v6 - uses: superfly/flyctl-actions/setup-flyctl@master - - name: Deploy to Fly.io - run: flyctl deploy --remote-only + - name: Deploy to staging + run: flyctl deploy --remote-only --config fly.staging.toml + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN_STAGING }} + + deploy-production: + name: Deploy to Fly.io (Production) + runs-on: ubuntu-latest + needs: check + if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' + environment: production + + steps: + - uses: actions/checkout@v6 + + - uses: superfly/flyctl-actions/setup-flyctl@master + + - name: Deploy to production + run: flyctl deploy --remote-only --config fly.toml env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..0c2cd62 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,273 @@ +# Deployment Guide + +## Environments + +| Environment | App | Trigger | Config | +|-------------|-----|---------|--------| +| Staging | `og-engine-staging` | Push to `dev` branch (via CI) | `fly.staging.toml` | +| Production | `og-engine` | Push a version tag `v*` (via CI) | `fly.toml` | + +> **Key rule:** Merging to `dev` deploys automatically to staging only. Production requires a deliberate version tag (`git tag v1.2.3 && git push --tags`). + +--- + +## Branch Protection + +Configure the following rules in **GitHub → Settings → Branches** for the `dev` branch: + +- Require pull request before merging (1 approval) +- Require status checks to pass: **CI / Lint, Type-check & Test** +- Require branches to be up to date before merging +- Do not allow bypassing the above settings + +This ensures no direct pushes reach `dev` without a passing CI build and peer review. + +--- + +## Prerequisites + +- [Fly.io CLI](https://fly.io/docs/hands-on/install-flyctl/) installed and authenticated +- A Fly.io Tigris object storage bucket (S3-compatible) + +## Initial Setup + +### 1. Create the Tigris Bucket + +```bash +fly storage create +``` + +Note the bucket name and credentials output. You'll need them in the next step. + +### 2. Set Required Secrets + +```bash +# Core secrets +fly secrets set STRIPE_SECRET_KEY=sk_live_... +fly secrets set STRIPE_WEBHOOK_SECRET=whsec_... +fly secrets set STRIPE_PRICE_STARTER=price_... +fly secrets set STRIPE_PRICE_PRO=price_... +fly secrets set STRIPE_PRICE_SCALE=price_... +fly secrets set RESEND_API_KEY=re_... + +# Admin cron authentication (generate a random secret) +fly secrets set ADMIN_CRON_SECRET=$(openssl rand -hex 32) + +# Admin email for backup failure alerts +fly secrets set ADMIN_EMAIL=admin@example.com + +# Tigris / S3 object storage (from `fly storage create` output) +fly secrets set TIGRIS_ACCESS_KEY_ID=tid_... +fly secrets set TIGRIS_SECRET_ACCESS_KEY=tsec_... +fly secrets set TIGRIS_BUCKET_NAME=your-bucket-name +# TIGRIS_ENDPOINT_URL defaults to https://fly.storage.tigris.dev +# TIGRIS_REGION defaults to auto +``` + +### 3. Set GitHub Actions Secrets + +In your GitHub repository → Settings → Secrets and variables → Actions, add: + +| Secret | Description | +|--------|-------------| +| `ADMIN_CRON_SECRET` | Same value as the Fly.io secret | +| `RESEND_API_KEY` | Same Resend API key | +| `ADMIN_EMAIL` | Admin email for failure alerts | + +### 4. Deploy + +```bash +fly deploy +``` + +--- + +## Staging Environment Setup + +Run these steps once to bootstrap the `og-engine-staging` Fly.io app. + +### 1. Create the app + +```bash +fly apps create og-engine-staging +``` + +### 2. Create a persistent volume (same region as production) + +```bash +fly volumes create og_engine_staging_data --region cdg --size 1 -a og-engine-staging +``` + +### 3. Set staging secrets + +Use **test-mode** Stripe keys and a separate Tigris bucket: + +```bash +fly secrets set -a og-engine-staging \ + STRIPE_SECRET_KEY=sk_test_... \ + STRIPE_WEBHOOK_SECRET=whsec_test_... \ + STRIPE_PRICE_STARTER=price_test_... \ + STRIPE_PRICE_PRO=price_test_... \ + STRIPE_PRICE_SCALE=price_test_... \ + RESEND_API_KEY=re_... \ + ADMIN_CRON_SECRET=$(openssl rand -hex 32) \ + ADMIN_EMAIL=admin@example.com \ + TIGRIS_ACCESS_KEY_ID=tid_... \ + TIGRIS_SECRET_ACCESS_KEY=tsec_... \ + TIGRIS_BUCKET_NAME=og-engine-staging-backups +``` + +### 4. Add GitHub Actions secret + +In **GitHub → Settings → Secrets → Actions**, add: + +| Secret | Description | +|--------|-------------| +| `FLY_API_TOKEN_STAGING` | Fly.io API token scoped to the staging app | + +> The production `FLY_API_TOKEN` secret already exists. The staging workflow uses the separate `FLY_API_TOKEN_STAGING` secret to limit blast radius. + +### 5. Initial deploy + +```bash +fly deploy --config fly.staging.toml +``` + +After this first manual deploy, all subsequent deploys happen automatically via CI on every push to `dev`. + +--- + +## Releasing to Production + +1. Ensure all desired changes are merged to `dev` and staging is healthy. +2. Tag the commit: + +```bash +git checkout dev +git pull +git tag v1.2.3 +git push origin v1.2.3 +``` + +3. CI runs `check` and then `deploy-production`, deploying to `og-engine` on Fly.io. + +--- + +## Database Backup Strategy + +OG Engine uses SQLite on a Fly.io persistent volume (`/data/og-engine.db`). +A daily automated backup job copies the database to Tigris object storage. + +### How It Works + +1. **GitHub Actions** triggers a `POST /admin/backup-db` request daily at **02:00 UTC**. +2. The app runs `VACUUM INTO '/tmp/backup-.db'` — an online, WAL-safe SQLite snapshot. +3. The snapshot is uploaded to Tigris under the key `backups/og-engine-.db`. +4. Backups older than **7 days** are automatically pruned. +5. If the backup fails, an alert email is sent via Resend to `ADMIN_EMAIL`. + +### Manual Trigger + +```bash +curl -X POST https://og-engine.com/admin/backup-db \ + -H "Authorization: Bearer $ADMIN_CRON_SECRET" \ + -H "Content-Type: application/json" +``` + +Expected response: +```json +{ + "success": true, + "key": "backups/og-engine-2026-04-16T02-00-00.db", + "sizeBytes": 1048576, + "pruned": [], + "timestamp": "2026-04-16T02:00:05.123Z" +} +``` + +### List Available Backups + +Using the AWS CLI (works with Tigris): + +```bash +AWS_ACCESS_KEY_ID=$TIGRIS_ACCESS_KEY_ID \ +AWS_SECRET_ACCESS_KEY=$TIGRIS_SECRET_ACCESS_KEY \ +aws s3 ls s3://$TIGRIS_BUCKET_NAME/backups/ \ + --endpoint-url https://fly.storage.tigris.dev +``` + +--- + +## Restore Procedure + +### Step 1 — Download a Backup + +```bash +# List backups to find the target +AWS_ACCESS_KEY_ID=$TIGRIS_ACCESS_KEY_ID \ +AWS_SECRET_ACCESS_KEY=$TIGRIS_SECRET_ACCESS_KEY \ +aws s3 ls s3://$TIGRIS_BUCKET_NAME/backups/ \ + --endpoint-url https://fly.storage.tigris.dev + +# Download the desired backup +AWS_ACCESS_KEY_ID=$TIGRIS_ACCESS_KEY_ID \ +AWS_SECRET_ACCESS_KEY=$TIGRIS_SECRET_ACCESS_KEY \ +aws s3 cp s3://$TIGRIS_BUCKET_NAME/backups/og-engine-2026-04-16T02-00-00.db \ + ./restored.db \ + --endpoint-url https://fly.storage.tigris.dev +``` + +### Step 2 — Verify Integrity + +```bash +sqlite3 ./restored.db "PRAGMA integrity_check;" +# Expected output: ok + +sqlite3 ./restored.db "SELECT COUNT(*) FROM users;" +# Verify row count looks reasonable +``` + +### Step 3 — Stop the Running Machine + +```bash +fly machine list +fly machine stop +``` + +### Step 4 — Copy the Backup onto the Volume + +```bash +# Open an SSH session and copy the file in +fly sftp shell +# Inside the SFTP shell: +put restored.db /data/og-engine.db +``` + +Alternatively, using `fly ssh console`: + +```bash +fly ssh console -C "cat > /data/og-engine.db" < ./restored.db +``` + +### Step 5 — Restart the Machine + +```bash +fly machine start +# Wait for health checks to pass +fly status +``` + +### Step 6 — Verify the App is Healthy + +```bash +curl https://og-engine.com/health +``` + +--- + +## Monitoring + +- **Backup history**: check the `Daily DB Backup` workflow in GitHub Actions. +- **Failure alerts**: an email is sent to `ADMIN_EMAIL` on any backup failure. +- **Volume health**: `fly volumes list` shows volume state. +- **Machine health**: `fly status` shows machine state and health check results. diff --git a/fly.staging.toml b/fly.staging.toml new file mode 100644 index 0000000..1c82f83 --- /dev/null +++ b/fly.staging.toml @@ -0,0 +1,58 @@ +# Fly.io deployment config for OG Engine — STAGING +# Deploy: fly deploy --config fly.staging.toml +# Secrets (set via fly secrets set -a og-engine-staging): +# Same secret names as production — use test/sandbox values for Stripe etc. +# ADMIN_CRON_SECRET - Shared secret for GitHub Action cron +# ADMIN_EMAIL - Admin email address for backup failure alerts +# TIGRIS_ACCESS_KEY_ID - Tigris / S3-compatible access key +# TIGRIS_SECRET_ACCESS_KEY - Tigris / S3-compatible secret key +# TIGRIS_BUCKET_NAME - Separate staging bucket name +# STRIPE_SECRET_KEY - Stripe test-mode API key (sk_test_...) +# STRIPE_WEBHOOK_SECRET - Stripe test-mode webhook signing secret +# STRIPE_PRICE_STARTER - Stripe test-mode Price ID for Starter plan +# STRIPE_PRICE_PRO - Stripe test-mode Price ID for Pro plan +# STRIPE_PRICE_SCALE - Stripe test-mode Price ID for Scale plan +# RESEND_API_KEY - Resend API key (can reuse same key) + +app = "og-engine-staging" +primary_region = "cdg" + +[build] + dockerfile = "Dockerfile" + +[env] + PORT = "3000" + NODE_ENV = "production" + DATABASE_URL = "file:/data/og-engine-staging.db" + IMAGE_CACHE_MAX = "500" + RATE_LIMIT_MAX = "200" + RATE_LIMIT_WINDOW_MS = "60000" + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] + + [http_service.concurrency] + type = "requests" + hard_limit = 100 + soft_limit = 80 + +[[http_service.checks]] + grace_period = "5s" + interval = "30s" + method = "GET" + path = "/health" + timeout = "5s" + +[mounts] + source = "og_engine_staging_data" + destination = "/data" + +[[vm]] + size = "shared-cpu-1x" + memory = "256mb" + cpus = 1 From 95b083d81de9580097be79c9a94eeba0202856ce Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 16 Apr 2026 23:37:16 +0200 Subject: [PATCH 185/189] feat(render): add WebP plan gate and tests for Phase 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add inline WebP plan check in POST /render after request parsing; free-tier users receive 402 plan_required when requesting output.format=webp - Import canAccessFeature and Plan type into render route - Add three plan-gating tests: free→402, starter→200, unauthenticated→200 Co-Authored-By: Paperclip --- src/api/render.ts | 24 +++++++++++++- tests/api/render.test.ts | 68 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/api/render.ts b/src/api/render.ts index d7713b4..f77909e 100644 --- a/src/api/render.ts +++ b/src/api/render.ts @@ -1,9 +1,10 @@ import { Hono } from 'hono'; -import { type ApiKeyRecord, findCustomTemplate } from '../db'; +import { type ApiKeyRecord, findCustomTemplate, type Plan } from '../db'; import type { CustomTemplateDefinition } from '../engine/custom-template'; import { getCachedImage, hashRequest, setCachedImage } from '../engine/image-cache'; import { loadRemoteImages } from '../engine/image-loader'; import { renderCard } from '../engine/renderer'; +import { canAccessFeature } from '../middleware/auth'; import { renderSchema } from '../schemas/request'; export const renderRoute = new Hono(); @@ -91,6 +92,27 @@ renderRoute.post('/render', async (c) => { const data = parsed.data; + // Plan gate: WebP requires Starter+ plan + if (data.output.format === 'webp') { + const apiKeyRecord = c.get('apiKey' as never) as ApiKeyRecord | undefined; + if (apiKeyRecord && !canAccessFeature(apiKeyRecord.plan as Plan, 'webp')) { + return c.json( + { + error: 'plan_required', + message: `This feature requires a higher plan. Your plan: ${apiKeyRecord.plan}.`, + details: { + feature: 'webp', + currentPlan: apiKeyRecord.plan, + requiredPlans: ['starter', 'pro', 'scale'], + upgradeUrl: 'https://og-engine.com/pricing', + }, + docs: 'https://og-engine.com/api-reference/errors#plan_required', + }, + 402, + ); + } + } + // Merge legacy content fields into variables const variables: Record = { title: data.title, diff --git a/tests/api/render.test.ts b/tests/api/render.test.ts index d46b8c8..b18c4a4 100644 --- a/tests/api/render.test.ts +++ b/tests/api/render.test.ts @@ -1,9 +1,11 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Hono } from 'hono'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { renderRoute } from '../../src/api/render'; +import { closeDb, createApiKey, updatePlan } from '../../src/db'; import { registerFonts } from '../../src/engine/fonts'; +import { authMiddleware } from '../../src/middleware/auth'; import { renderSchema } from '../../src/schemas/request'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -169,6 +171,70 @@ describe('renderSchema with variables', () => { }); }); +describe('POST /render WebP plan gating', () => { + beforeEach(() => { + closeDb(); + process.env.DATABASE_URL = 'file::memory:'; + }); + + afterAll(() => { + closeDb(); + delete process.env.DATABASE_URL; + }); + + function createAuthApp() { + const app = new Hono(); + app.use('/render', authMiddleware()); + app.route('/', renderRoute); + return app; + } + + it('returns 402 for free-tier users requesting WebP', async () => { + const record = createApiKey('free@example.com'); // free plan by default + const app = createAuthApp(); + const res = await app.request('/render', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${record.key}`, + }, + body: JSON.stringify({ format: 'og', title: 'WebP Test', output: { format: 'webp' } }), + }); + expect(res.status).toBe(402); + const body = await res.json(); + expect(body.error).toBe('plan_required'); + expect(body.details.feature).toBe('webp'); + }); + + it('returns 200 for starter-plan users requesting WebP', async () => { + const record = createApiKey('starter@example.com'); + updatePlan(record.id, 'starter'); + const app = createAuthApp(); + const res = await app.request('/render', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${record.key}`, + }, + body: JSON.stringify({ format: 'og', title: 'WebP Test', output: { format: 'webp' } }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/webp'); + }); + + it('allows unauthenticated WebP requests (auth disabled context)', async () => { + const app = new Hono(); + app.route('/', renderRoute); + const res = await app.request('/render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ format: 'og', title: 'WebP Test', output: { format: 'webp' } }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/webp'); + }); +}); + describe('renderSchema with images', () => { it('accepts a request with named image URLs', () => { const result = renderSchema.safeParse({ From 7a6e856a2a2ab7b0b8945f8829caa4312468c4ed Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 16 Apr 2026 23:39:24 +0200 Subject: [PATCH 186/189] feat(cache): add LRU text measurement cache hit rate tracking (ATY-19) - Track cache hits/misses/hitRate in measureLines() - Expose stats via getMeasureCacheStats() for /health endpoint - Clear counters when clearMeasureCache() is called - Add cache hit rate tests and throughput benchmark in text-measure.test.ts Co-Authored-By: Paperclip --- src/api/health.ts | 4 +++ src/engine/text-measure.ts | 20 +++++++++++++- tests/engine/text-measure.test.ts | 44 +++++++++++++++++++++++++++++-- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/api/health.ts b/src/api/health.ts index 19cbf5c..af3f130 100644 --- a/src/api/health.ts +++ b/src/api/health.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { FONTS } from '../engine/fonts'; import { FORMAT_KEYS } from '../engine/formats'; import { TEMPLATE_NAMES } from '../engine/templates'; +import { getMeasureCacheStats } from '../engine/text-measure'; export const healthRoute = new Hono(); @@ -12,5 +13,8 @@ healthRoute.get('/health', (c) => { formats: FORMAT_KEYS, templates: TEMPLATE_NAMES, version: '0.1.0', + cache: { + textMeasure: getMeasureCacheStats(), + }, }); }); diff --git a/src/engine/text-measure.ts b/src/engine/text-measure.ts index bcfac38..0e422d8 100644 --- a/src/engine/text-measure.ts +++ b/src/engine/text-measure.ts @@ -10,13 +10,19 @@ const measureCanvas = createCanvas(1, 1); const measureCtx = measureCanvas.getContext('2d'); const lineCache = new LRUCache(2000); +let cacheHits = 0; +let cacheMisses = 0; export function measureLines(text: string, font: string, maxWidth: number): MeasuredLine[] { if (!text || maxWidth <= 0) return []; const cacheKey = `${text}\0${font}\0${maxWidth}`; const cached = lineCache.get(cacheKey); - if (cached) return cached; + if (cached) { + cacheHits++; + return cached; + } + cacheMisses++; measureCtx.font = font; const lines: MeasuredLine[] = []; @@ -54,12 +60,24 @@ export function measureLines(text: string, font: string, maxWidth: number): Meas export function clearMeasureCache(): void { lineCache.clear(); + cacheHits = 0; + cacheMisses = 0; } export function getMeasureCacheSize(): number { return lineCache.size; } +export function getMeasureCacheStats(): { hits: number; misses: number; size: number; hitRate: number } { + const total = cacheHits + cacheMisses; + return { + hits: cacheHits, + misses: cacheMisses, + size: lineCache.size, + hitRate: total === 0 ? 0 : Math.round((cacheHits / total) * 10000) / 100, + }; +} + export function measureTextWidth(text: string, font: string): number { if (!text) return 0; measureCtx.font = font; diff --git a/tests/engine/text-measure.test.ts b/tests/engine/text-measure.test.ts index aed1aa4..6df9180 100644 --- a/tests/engine/text-measure.test.ts +++ b/tests/engine/text-measure.test.ts @@ -1,8 +1,8 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { registerFonts } from '../../src/engine/fonts'; -import { measureLines, measureTextWidth } from '../../src/engine/text-measure'; +import { clearMeasureCache, getMeasureCacheStats, measureLines, measureTextWidth } from '../../src/engine/text-measure'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -10,6 +10,10 @@ beforeAll(async () => { await registerFonts(join(__dirname, '..', '..', 'fonts')); }); +beforeEach(() => { + clearMeasureCache(); +}); + describe('measureLines', () => { it('returns empty array for empty text', () => { expect(measureLines('', '400 48px Outfit', 800)).toEqual([]); @@ -49,3 +53,39 @@ describe('measureTextWidth', () => { expect(w).toBe(0); }); }); + +describe('LRU cache hit rate', () => { + it('reports zero hits on first call', () => { + measureLines('Hello world', '400 48px Outfit', 800); + const stats = getMeasureCacheStats(); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBe(0); + }); + + it('reports 100% hit rate on repeated identical call', () => { + measureLines('Hello world', '400 48px Outfit', 800); + measureLines('Hello world', '400 48px Outfit', 800); + const stats = getMeasureCacheStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBe(50); + }); + + it('cache is faster on repeated calls', () => { + const text = 'Benchmarking LRU cache performance for repeated text measurement calls'; + const font = '700 48px Outfit'; + const maxWidth = 1000; + const iterations = 500; + + const t0 = performance.now(); + for (let i = 0; i < iterations; i++) measureLines(text, font, maxWidth); + const elapsed = performance.now() - t0; + + const stats = getMeasureCacheStats(); + // After first call all subsequent are cache hits — hit rate should be > 99% + expect(stats.hitRate).toBeGreaterThan(99); + // 500 cached lookups should complete well under 100ms + expect(elapsed).toBeLessThan(100); + }); +}); From ff9aca4686db14e2718afd65874f2ee84c0c4348 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 16 Apr 2026 23:41:15 +0200 Subject: [PATCH 187/189] feat(sdk): dual-publish to npm registry and GitHub Packages (ATY-20) - Update sdk/package.json publishConfig to target npm registry with public access - Add publish-sdk-npm CI job triggered on v* tags using NPM_TOKEN secret - Rename publish-sdk to publish-sdk-github for clarity - Fix version-check in GitHub job to query correct registry - Use explicit --registry flag in GitHub Packages publish command Co-Authored-By: Paperclip --- .github/workflows/ci.yml | 57 +++++++++++++++++++++++++++++++++++++--- sdk/package.json | 3 ++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f31f381..391fe42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: env: AUTH_ENABLED: "false" - publish-sdk: + publish-sdk-github: name: Publish SDK to GitHub Packages runs-on: ubuntu-latest needs: check @@ -72,7 +72,7 @@ jobs: run: | PACKAGE_NAME=$(node -p "require('./package.json').name") PACKAGE_VERSION=$(node -p "require('./package.json').version") - if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>/dev/null; then + if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --registry https://npm.pkg.github.com 2>/dev/null; then echo "published=true" >> "$GITHUB_OUTPUT" echo "Version ${PACKAGE_VERSION} already published — skipping" else @@ -84,10 +84,61 @@ jobs: - name: Publish to GitHub Packages if: steps.version-check.outputs.published == 'false' - run: npm publish + run: npm publish --registry https://npm.pkg.github.com env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + publish-sdk-npm: + name: Publish SDK to npm registry + runs-on: ubuntu-latest + needs: check + if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' + permissions: + contents: read + defaults: + run: + working-directory: sdk + + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + scope: '@atypical-consulting' + + - name: Install dependencies + run: bun install + + - name: Build SDK + run: bun run build + + - name: Check if version is already published + id: version-check + run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") + PACKAGE_VERSION=$(node -p "require('./package.json').version") + if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>/dev/null; then + echo "published=true" >> "$GITHUB_OUTPUT" + echo "Version ${PACKAGE_VERSION} already published — skipping" + else + echo "published=false" >> "$GITHUB_OUTPUT" + echo "Version ${PACKAGE_VERSION} not yet published — will publish" + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish to npm + if: steps.version-check.outputs.published == 'false' + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + deploy-staging: name: Deploy to Fly.io (Staging) runs-on: ubuntu-latest diff --git a/sdk/package.json b/sdk/package.json index e80a12a..4c42977 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -27,7 +27,8 @@ ], "license": "Apache-2.0", "publishConfig": { - "registry": "https://npm.pkg.github.com" + "registry": "https://registry.npmjs.org", + "access": "public" }, "repository": { "type": "git", From 191112f1cda7305620645ced033dfd1e7adc4aef Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 16 Apr 2026 23:44:20 +0200 Subject: [PATCH 188/189] fix(security): block SSRF in POST /render/from-url (ATY-13) Resolves the hostname of every user-supplied URL before fetching it and returns HTTP 400 when the resolved IP falls in a private or reserved range (RFC 1918, loopback, link-local / 169.254.x.x, CGNAT, IPv6 loopback and link-local). - src/utils/ssrf.ts: assertNotPrivateHost() + SsrfBlockedError - src/api/render-from-url.ts: SSRF guard before fetch() - tests/api/render-from-url.test.ts: 8 new SSRF test cases Co-Authored-By: Paperclip --- src/api/render-from-url.ts | 20 ++++++++ src/utils/ssrf.ts | 82 +++++++++++++++++++++++++++++++ tests/api/render-from-url.test.ts | 36 ++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 src/utils/ssrf.ts diff --git a/src/api/render-from-url.ts b/src/api/render-from-url.ts index a7a1d15..96ef1d3 100644 --- a/src/api/render-from-url.ts +++ b/src/api/render-from-url.ts @@ -7,6 +7,7 @@ import { loadRemoteImages } from '../engine/image-loader'; import { extractMeta } from '../engine/meta-extract'; import { renderCard } from '../engine/renderer'; import { TEMPLATE_NAMES } from '../engine/templates'; +import { assertNotPrivateHost, SsrfBlockedError } from '../utils/ssrf'; const fontNames = FONTS.map((f) => f.name); const gradientSlugs = GRADIENTS.map((g) => g.slug); @@ -98,6 +99,25 @@ renderFromUrlRoute.post('/render/from-url', async (c) => { const data = parsed.data; + // SSRF protection: block private/reserved IPs before fetching + try { + const { hostname } = new URL(data.url); + await assertNotPrivateHost(hostname); + } catch (err) { + if (err instanceof SsrfBlockedError) { + return c.json( + { + error: 'ssrf_blocked', + message: err.message, + docs: 'https://og-engine.com/api-reference/errors#ssrf_blocked', + }, + 400, + ); + } + // URL parse failure is already caught by schema validation; rethrow unexpected errors + throw err; + } + // Fetch the URL let html: string; try { diff --git a/src/utils/ssrf.ts b/src/utils/ssrf.ts new file mode 100644 index 0000000..2eec004 --- /dev/null +++ b/src/utils/ssrf.ts @@ -0,0 +1,82 @@ +import { lookup } from 'node:dns/promises'; + +interface CidrBlock { + base: number; + mask: number; +} + +function ipv4ToInt(ip: string): number | null { + const parts = ip.split('.'); + if (parts.length !== 4) return null; + let n = 0; + for (const part of parts) { + const byte = parseInt(part, 10); + if (Number.isNaN(byte) || byte < 0 || byte > 255) return null; + n = (n << 8) | byte; + } + return n >>> 0; +} + +function cidr(ip: string, prefix: number): CidrBlock { + const base = ipv4ToInt(ip)!; + const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; + return { base: base & mask, mask }; +} + +const BLOCKED_IPV4: CidrBlock[] = [ + cidr('127.0.0.0', 8), // loopback + cidr('10.0.0.0', 8), // RFC 1918 private + cidr('172.16.0.0', 12), // RFC 1918 private + cidr('192.168.0.0', 16), // RFC 1918 private + cidr('169.254.0.0', 16), // link-local (AWS/Fly metadata) + cidr('100.64.0.0', 10), // CGNAT shared address space + cidr('0.0.0.0', 8), // "this" network +]; + +const BLOCKED_IPV6_PREFIXES = [ + '::1', // loopback + 'fc', // unique local (fc00::/7) + 'fd', // unique local (fd00::/7) + 'fe80', // link-local (fe80::/10) + '::ffff:', // IPv4-mapped (::ffff:0:0/96) +]; + +function isBlockedIPv4(ip: string): boolean { + const n = ipv4ToInt(ip); + if (n === null) return false; + return BLOCKED_IPV4.some(({ base, mask }) => (n & mask) === base); +} + +function isBlockedIPv6(ip: string): boolean { + const lower = ip.toLowerCase().replace(/^\[|\]$/g, ''); + return BLOCKED_IPV6_PREFIXES.some((prefix) => lower === prefix || lower.startsWith(prefix)); +} + +export class SsrfBlockedError extends Error { + constructor(hostname: string, ip: string) { + super(`Blocked: "${hostname}" resolves to a private/reserved IP (${ip})`); + this.name = 'SsrfBlockedError'; + } +} + +/** + * Resolve `hostname` to an IP and throw SsrfBlockedError if it falls in a + * private or reserved range. Call this before fetching any user-supplied URL. + */ +export async function assertNotPrivateHost(hostname: string): Promise { + let ip: string; + try { + const result = await lookup(hostname, { verbatim: false }); + ip = result.address; + } catch { + // DNS failure — let the subsequent fetch() surface the real error + return; + } + + const family = ip.includes(':') ? 6 : 4; + const blocked = family === 4 ? isBlockedIPv4(ip) : isBlockedIPv6(ip); + + if (blocked) { + throw new SsrfBlockedError(hostname, ip); + } +} diff --git a/tests/api/render-from-url.test.ts b/tests/api/render-from-url.test.ts index b3bd353..d147a7d 100644 --- a/tests/api/render-from-url.test.ts +++ b/tests/api/render-from-url.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { renderFromUrlSchema } from '../../src/api/render-from-url'; +import { assertNotPrivateHost, SsrfBlockedError } from '../../src/utils/ssrf'; describe('renderFromUrlSchema', () => { it('accepts a valid URL request', () => { @@ -41,3 +42,38 @@ describe('renderFromUrlSchema', () => { } }); }); + +describe('assertNotPrivateHost (SSRF protection)', () => { + it('throws SsrfBlockedError for loopback 127.0.0.1', async () => { + await expect(assertNotPrivateHost('127.0.0.1')).rejects.toThrow(SsrfBlockedError); + }); + + it('throws SsrfBlockedError for localhost resolving to 127.0.0.1', async () => { + // localhost always resolves to 127.0.0.1 or ::1 — both are blocked + await expect(assertNotPrivateHost('localhost')).rejects.toThrow(SsrfBlockedError); + }); + + it('throws SsrfBlockedError for RFC 1918 range 10.0.0.1', async () => { + await expect(assertNotPrivateHost('10.0.0.1')).rejects.toThrow(SsrfBlockedError); + }); + + it('throws SsrfBlockedError for RFC 1918 range 172.16.0.1', async () => { + await expect(assertNotPrivateHost('172.16.0.1')).rejects.toThrow(SsrfBlockedError); + }); + + it('throws SsrfBlockedError for RFC 1918 range 192.168.1.1', async () => { + await expect(assertNotPrivateHost('192.168.1.1')).rejects.toThrow(SsrfBlockedError); + }); + + it('throws SsrfBlockedError for link-local / metadata endpoint 169.254.169.254', async () => { + await expect(assertNotPrivateHost('169.254.169.254')).rejects.toThrow(SsrfBlockedError); + }); + + it('throws SsrfBlockedError for IPv6 loopback ::1', async () => { + await expect(assertNotPrivateHost('::1')).rejects.toThrow(SsrfBlockedError); + }); + + it('does not throw for a public IP (8.8.8.8)', async () => { + await expect(assertNotPrivateHost('8.8.8.8')).resolves.toBeUndefined(); + }); +}); From ed4ceece1b518f283bd043c7732838334eaf6dbf Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Thu, 16 Apr 2026 23:45:51 +0200 Subject: [PATCH 189/189] chore(release): populate LICENSE-HISTORY.md dates for v0.1.0 Release date: 2026-04-16 Apache-2.0 conversion date: 2028-04-16 Co-Authored-By: Paperclip --- LICENSE-HISTORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE-HISTORY.md b/LICENSE-HISTORY.md index f8705d4..e6cc9cd 100644 --- a/LICENSE-HISTORY.md +++ b/LICENSE-HISTORY.md @@ -7,7 +7,7 @@ date**. This table records the converted-on date for each release. | Version | Release Date | Converts to Apache-2.0 on | |---------|--------------|---------------------------| -| 0.1.0 | TBD | TBD (release date + 2 years) | +| 0.1.0 | 2026-04-16 | 2028-04-16 | ## How this gets updated