{
  "openapi": "3.1.0",
  "info": {
    "title": "tuned.page API",
    "version": "0.1.0",
    "description": "Kendini güncelleyen link-in-bio sayfası. Uçlar iki gruba ayrılıyor: **herkese açık** (sayfa görüntüleme, ad müsaitliği, ad ayırtma) ve **oturumlu** (sayfa düzenleme, tile ekleme, yükleme, hesap silme).\n\nDurum değiştiren her uç aynı kökenden gelmeyi şart koşuyor (`Origin` ya da `Referer`); jeton tabanlı bir genel API değil, kendi arayüzümüzün sözleşmesi.",
    "contact": {
      "name": "açıkta geliştiriliyor",
      "url": "https://tuned.page"
    }
  },
  "servers": [
    {
      "url": "https://tuned.page",
      "description": "üretim"
    }
  ],
  "tags": [
    {
      "name": "public",
      "description": "Oturum istemeyen uçlar"
    },
    {
      "name": "auth",
      "description": "Giriş ve oturum"
    },
    {
      "name": "page",
      "description": "Sayfa ve tile yönetimi — sahiplik gerekiyor"
    },
    {
      "name": "feed",
      "description": "Makineler için: robots, sitemap, llms, OG"
    },
    {
      "name": "webhook",
      "description": "Platformlardan gelen bildirimler"
    }
  ],
  "components": {
    "securitySchemes": {
      "session": {
        "type": "apiKey",
        "in": "cookie",
        "name": "tuned_session",
        "description": "HttpOnly çerez. Jetonun kendisi saklanmıyor, SHA-256 özeti saklanıyor."
      }
    },
    "schemas": {
      "Tile": {
        "type": "object",
        "required": [
          "id",
          "kind"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "live",
              "earn",
              "static"
            ]
          },
          "size": {
            "type": "string",
            "pattern": "^[1-4]x[1-4]$",
            "example": "2x1"
          },
          "provider": {
            "type": "string",
            "example": "youtube"
          },
          "handle": {
            "type": "string"
          },
          "href": {
            "type": "string",
            "format": "uri"
          },
          "title": {
            "type": "string"
          },
          "body": {
            "type": "string"
          },
          "imageUrl": {
            "type": "string"
          },
          "syncedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SlugCheck": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "slug": {
            "type": "string"
          },
          "reason": {
            "type": "string",
            "description": "Yalnızca `ok: false` iken"
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "reason": {
            "type": "string"
          }
        }
      }
    }
  },
  "paths": {
    "/api/slug": {
      "get": {
        "tags": [
          "public"
        ],
        "summary": "Kullanıcı adı müsait mi",
        "description": "Arayüz ipucu. Kesin cevap `POST /api/pages`'teki `INSERT`ten gelir: iki kişi aynı anda sorarsa ikisi de 'boşta' görebilir.\n\nYayınlanmış sayfalar VE ayrılmış adlar birlikte sayılıyor.",
        "parameters": [
          {
            "name": "s",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "mira"
          },
          {
            "name": "lang",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "tr",
                "en"
              ],
              "default": "tr"
            },
            "description": "Hata metninin dili"
          }
        ],
        "responses": {
          "200": {
            "description": "Sonuç",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SlugCheck"
                }
              }
            }
          }
        }
      }
    },
    "/api/reserve": {
      "post": {
        "tags": [
          "public"
        ],
        "summary": "Kullanıcı adını ayırt",
        "description": "Giriş açılana kadar adı e-postaya bağlar. Bir SÖZ değil bir SIRA: adres doğrulanmıyor (posta doğrulaması giriş anında yapılıyor), dolayısıyla tek koruma adres başına tek ad.\n\nAynı kişi aynı adı tekrar yollarsa hata değil `ok: true` döner.",
        "parameters": [
          {
            "name": "lang",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "tr",
                "en"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "slug",
                  "email"
                ],
                "properties": {
                  "slug": {
                    "type": "string",
                    "example": "mira"
                  },
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ayrıldı"
          },
          "400": {
            "description": "Geçersiz ad ya da e-posta",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Farklı köken"
          },
          "409": {
            "description": "Ad alınmış ya da bu adresin zaten bir adı var"
          }
        }
      }
    },
    "/api/auth/link": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Giriş bağlantısı iste",
        "description": "Cevap HER DURUMDA aynı: adresin kayıtlı olup olmadığı, hız sınırına takılıp takılmadığı dışarıya sızmıyor. Tek istisna biçim hatası — kullanıcı kendi yazım hatasını göremezse takılır kalır.\n\nBağlantı 15 dakika geçerli, tek kullanımlık. Adres başına saatte 5 istek.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "İstek alındı (gönderildiği anlamına gelmez)"
          },
          "400": {
            "description": "Geçersiz e-posta biçimi"
          },
          "403": {
            "description": "Farklı köken"
          },
          "503": {
            "description": "Posta gönderimi kurulu değil"
          }
        }
      }
    },
    "/api/auth/verify": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Bağlantıyı harca, oturum aç",
        "description": "**POST, GET değil.** Kurumsal e-posta tarayıcıları gelen kutusundaki her bağlantıyı açıyor; jeton GET'te harcansa kullanıcı tıklamadan girişi yanardı. Tek kullanım `UPDATE ... WHERE used_at IS NULL` ile veritabanında zorlanıyor.",
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "t"
                ],
                "properties": {
                  "t": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "303": {
            "description": "Sayfası varsa editöre, yoksa `/start`e"
          },
          "400": {
            "description": "Jeton yok"
          },
          "403": {
            "description": "Farklı köken"
          }
        }
      }
    },
    "/api/auth/session": {
      "delete": {
        "tags": [
          "auth"
        ],
        "summary": "Çıkış",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "204": {
            "description": "Oturum kapatıldı"
          },
          "401": {
            "description": "Oturum yok"
          },
          "403": {
            "description": "Farklı köken ya da sayfa başkasının"
          }
        }
      }
    },
    "/api/pages": {
      "post": {
        "tags": [
          "page"
        ],
        "summary": "Sayfa yarat (kullanıcı adı sahiplen)",
        "description": "Kişi başına tek sayfa. Çakışma `INSERT`in kendisinde yakalanıyor — 'önce sorgula sonra yaz' iki kullanıcının aynı anda aynı adı almasına açık kapı bırakırdı. Başkasının ayırttığı ad reddediliyor.",
        "security": [
          {
            "session": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "slug"
                ],
                "properties": {
                  "slug": {
                    "type": "string",
                    "minLength": 3,
                    "maxLength": 30
                  },
                  "name": {
                    "type": "string"
                  },
                  "kind": {
                    "type": "string",
                    "enum": [
                      "musician",
                      "streamer",
                      "developer",
                      "writer",
                      "person"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Yaratıldı (ya da zaten vardı)"
          },
          "400": {
            "description": "Geçersiz ad"
          },
          "401": {
            "description": "Oturum yok"
          },
          "403": {
            "description": "Farklı köken ya da sayfa başkasının"
          },
          "409": {
            "description": "Ad alınmış"
          }
        }
      }
    },
    "/api/pages/{slug}": {
      "patch": {
        "tags": [
          "page"
        ],
        "summary": "Sayfayı kaydet",
        "description": "Editörün tek yazma ucu. İstemciden gelen hiçbir alana güvenilmiyor: yalnızca düzen ve statik metin geçiyor. Canlı tile `data`'sı ve `syncedAt` istemciden GÜNCELLENEMİYOR — onlar webhook'un işi.\n\nKaydetme abonelikleri bildiriyor ve uzlaştırmayı `waitUntil` ile hemen tetikliyor; cron yedek.",
        "security": [
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "bio": {
                    "type": "string"
                  },
                  "accent": {
                    "type": "string",
                    "description": "Yalnızca paletten"
                  },
                  "ground": {
                    "type": "string",
                    "enum": [
                      "auto",
                      "dark",
                      "light"
                    ]
                  },
                  "surface": {
                    "type": "string",
                    "enum": [
                      "glass",
                      "flat"
                    ]
                  },
                  "tiles": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/Tile"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Kaydedildi"
          },
          "401": {
            "description": "Oturum yok"
          },
          "403": {
            "description": "Farklı köken ya da sayfa başkasının"
          },
          "404": {
            "description": "Sayfa yok"
          }
        }
      }
    },
    "/api/pages/{slug}/tiles": {
      "post": {
        "tags": [
          "page"
        ],
        "summary": "Tile ekle",
        "description": "Ürünün en kritik anı: kullanıcı yalnızca kullanıcı adını yazıyor, tile kendini dolduruyor. İlk veri burada, istek sırasında çekiliyor — webhook beklenmiyor. Çekim başarısız olursa tile yine ekleniyor.",
        "security": [
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "provider"
                ],
                "properties": {
                  "provider": {
                    "type": "string",
                    "example": "youtube"
                  },
                  "handle": {
                    "type": "string",
                    "example": "@mkbhd"
                  },
                  "title": {
                    "type": "string"
                  },
                  "imageUrl": {
                    "type": "string",
                    "description": "Yalnızca `/img/` ile başlayan yüklenmiş adres"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Eklendi",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tile"
                }
              }
            }
          },
          "400": {
            "description": "Eksik alan"
          },
          "401": {
            "description": "Oturum yok"
          },
          "403": {
            "description": "Farklı köken ya da sayfa başkasının"
          },
          "409": {
            "description": "Bu hesap zaten ekli"
          }
        }
      }
    },
    "/api/pages/{slug}/upload": {
      "post": {
        "tags": [
          "page"
        ],
        "summary": "Görsel yükle",
        "description": "İstemcinin bildirdiği `content-type`'a **güvenilmiyor**: dosyanın ilk baytları okunup gerçek tipi belirleniyor. SVG kasten reddediliyor — betik taşıyabiliyor ve kendi alan adımızdan servis edilirse XSS olur.",
        "security": [
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Yüklendi",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "url": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Oturum yok"
          },
          "403": {
            "description": "Farklı köken ya da sayfa başkasının"
          },
          "413": {
            "description": "8 MB'tan büyük"
          },
          "415": {
            "description": "Desteklenmeyen biçim (SVG dâhil)"
          },
          "503": {
            "description": "Depolama kurulu değil"
          }
        }
      }
    },
    "/api/pages/{slug}/discover": {
      "get": {
        "tags": [
          "page"
        ],
        "summary": "Aynı adı başka platformlarda ara",
        "description": "Beş platform paralel yoklanıyor (GitHub, Bluesky, YouTube, Gumroad, Patreon). Yalnızca durum koduyla ayırt edenler: Bandcamp ve Twitch olmayan kullanıcı için de 200 dönüyor (yumuşak 404), Ko-fi bot engelli.\n\nAynı adın başka platformda bulunması aynı kişi olduğunu KANITLAMAZ; arayüz bunu bir öneri olarak sunuyor.",
        "security": [
          {
            "session": []
          }
        ],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "handle",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Bulunanlar"
          },
          "400": {
            "description": "Geçersiz kullanıcı adı"
          },
          "401": {
            "description": "Oturum yok"
          },
          "403": {
            "description": "Farklı köken ya da sayfa başkasının"
          }
        }
      }
    },
    "/api/account": {
      "delete": {
        "tags": [
          "auth"
        ],
        "summary": "Hesabı sil",
        "description": "Sayfa, abonelikler, oturumlar, giriş bağlantıları, yüklenen görseller ve kullanıcı kaydı tek batch'te siliniyor. Geri alma yok.\n\nHub'a `unsubscribe` çağrılmıyor: lease kısa ve yenileme yalnızca var olan satırlar için çalışıyor — silme hakkı üçüncü tarafın uptime'ına bağlanamaz.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "200": {
            "description": "Silindi"
          },
          "401": {
            "description": "Oturum yok"
          },
          "403": {
            "description": "Farklı köken ya da sayfa başkasının"
          }
        }
      }
    },
    "/api/og/{slug}.png": {
      "get": {
        "tags": [
          "feed"
        ],
        "summary": "Paylaşım görseli (1200×630)",
        "description": "Sayfanın gerçek tile'larından üretiliyor; sayfanın diline uyuyor. `?v=` sürüm damgası: platformların önizleme cache'i agresif, içerik değişince adres de değişmeli.",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "v",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "İçerik sürümü"
          }
        ],
        "responses": {
          "200": {
            "description": "PNG",
            "content": {
              "image/png": {}
            }
          },
          "404": {
            "description": "Sayfa yok"
          }
        }
      }
    },
    "/api/webhook/{provider}": {
      "post": {
        "tags": [
          "webhook"
        ],
        "summary": "Platform bildirimi",
        "description": "Üç protokol, üç ayrı imza şeması: YouTube WebSub (HMAC-SHA1), Twitch EventSub (HMAC-SHA256 + zaman damgası), GitHub (HMAC-SHA256).\n\nTekrar eden bildirimler eleniyor: sağlayıcının teslimat kimliği yoksa gövdenin SHA-256'sı kullanılıyor. Uzunluk+önek gibi bir anahtar çarpışıyor ve gerçek olayları sessizce düşürüyordu.",
        "parameters": [
          {
            "name": "provider",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "youtube",
                "twitch",
                "github"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "İşlendi ya da doğrulama yanıtı"
          },
          "202": {
            "description": "Tekrar — yok sayıldı"
          },
          "403": {
            "description": "İmza doğrulanamadı"
          }
        }
      }
    }
  }
}