feat(api): complete phase 9 mobile contracts

This commit is contained in:
poyrazavsever
2026-07-17 09:38:22 +03:00
parent 46a746d2d4
commit b04031a3e3
25 changed files with 4896 additions and 19 deletions
+3
View File
@@ -24,3 +24,6 @@ DATABASE_PATH=
OLLAMA_BASE_URL=http://127.0.0.1:11434/v1 OLLAMA_BASE_URL=http://127.0.0.1:11434/v1
AI_REQUEST_TIMEOUT_MS=30000 AI_REQUEST_TIMEOUT_MS=30000
# Optional SemVer floor advertised to future iOS/Android clients. Empty disables enforcement.
NETA_MINIMUM_MOBILE_VERSION=
+17
View File
@@ -73,6 +73,7 @@ Opsiyonel alanlar:
- `DATABASE_PATH`: Varsayılan `DATA_DIR/neta.db` yerine özel SQLite yolu. - `DATABASE_PATH`: Varsayılan `DATA_DIR/neta.db` yerine özel SQLite yolu.
- `OLLAMA_BASE_URL`: Varsayılan `http://127.0.0.1:11434/v1`. - `OLLAMA_BASE_URL`: Varsayılan `http://127.0.0.1:11434/v1`.
- `AI_REQUEST_TIMEOUT_MS`: AI istek timeout'u; varsayılan `30000`. - `AI_REQUEST_TIMEOUT_MS`: AI istek timeout'u; varsayılan `30000`.
- `NETA_MINIMUM_MOBILE_VERSION`: Mobil istemcilere ilan edilen opsiyonel SemVer alt sınırı.
AI provider API key'leri environment'a yazılmaz; owner ayarından girilir, server-side şifreli saklanır ve browser'a geri dönmez. AI provider API key'leri environment'a yazılmaz; owner ayarından girilir, server-side şifreli saklanır ve browser'a geri dönmez.
@@ -160,12 +161,28 @@ pnpm db:import:supabase -- \
Raporu doğruladıktan ve backup aldıktan sonra aynı komutu `--dry-run` olmadan çalıştırın. Bundle formatı, normalization kararları, dosya yapısı ve production cutover/rollback adımları [Faz 8 rehberinde](docs/self-hosted-redesign/phase-8-import-release.md) tanımlıdır. Raporu doğruladıktan ve backup aldıktan sonra aynı komutu `--dry-run` olmadan çalıştırın. Bundle formatı, normalization kararları, dosya yapısı ve production cutover/rollback adımları [Faz 8 rehberinde](docs/self-hosted-redesign/phase-8-import-release.md) tanımlıdır.
## Mobil istemci ve instance discovery
React Native istemcileri bir Neta kurulumunu şu public endpoint'lerle tanıyabilir:
```text
GET /.well-known/neta
GET /api/v1/meta
GET /api/v1/health
GET /api/v1/me
```
`/.well-known/neta` kalıcı instance kimliğini ve API URL'sini, `/api/v1/meta` marka/sürüm/capability sözleşmesini döndürür. `/api/v1/me` Better Auth session gerektirir ve token veya secret döndürmez.
Device pairing henüz runtime'a açılmamıştır; capability `planned` durumundadır. Mobil bağlantı algoritması ve API version kuralları [Faz 9 rehberinde](docs/self-hosted-redesign/phase-9-mobile-api.md), gelecek pairing/token güvenliği [ADR-0018](docs/self-hosted-redesign/adr-0018-device-pairing.md) belgesinde tanımlıdır.
## Kalite kontrolleri ## Kalite kontrolleri
```bash ```bash
pnpm typecheck pnpm typecheck
pnpm phase8:release-boundary pnpm phase8:release-boundary
pnpm phase8:import-smoke pnpm phase8:import-smoke
pnpm phase9:smoke
pnpm build pnpm build
``` ```
+34
View File
@@ -0,0 +1,34 @@
import { getNetaDiscoveryDocument } from "@/server/api/v1/runtime";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export function GET() {
try {
return Response.json(getNetaDiscoveryDocument(), {
headers: {
"Cache-Control": "public, max-age=60, stale-while-revalidate=300",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
console.error("Neta discovery failed", error);
return Response.json(
{
protocol: "neta",
discoveryVersion: 1,
error: {
code: "SERVICE_UNAVAILABLE",
message: "Instance keşif bilgisi geçici olarak kullanılamıyor.",
},
},
{
status: 503,
headers: {
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
},
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { apiV1Error, apiV1Success } from "@/server/api/v1/responses";
import { checkReadiness } from "@/server/db/health";
import { DomainError } from "@/server/domain/errors";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export function GET() {
const readiness = checkReadiness();
const checkedAt = new Date().toISOString();
if (!readiness.ok) {
return apiV1Error(
new DomainError(
"SERVICE_UNAVAILABLE",
"Instance henüz isteklere hazır değil.",
{ checks: readiness.checks, checkedAt },
),
);
}
return apiV1Success({
status: "ok",
checks: readiness.checks,
checkedAt,
});
}
+36
View File
@@ -0,0 +1,36 @@
import { apiV1Error, apiV1Success } from "@/server/api/v1/responses";
import { getSessionContextFromHeaders } from "@/server/auth/session";
import { getServerConfig } from "@/server/config";
import { DomainError } from "@/server/domain/errors";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
try {
const context = await getSessionContextFromHeaders(new Headers(request.headers));
if (!context) {
throw new DomainError("UNAUTHENTICATED", "Geçerli bir oturum gerekli.");
}
return apiV1Success({
user: {
id: context.user.id,
email: context.profile.email,
displayName: context.profile.displayName,
role: context.profile.role,
clientId: context.profile.clientId,
imageUrl: absoluteOptionalUrl(context.user.image),
},
session: {
expiresAt: context.session.expiresAt.toISOString(),
},
});
} catch (error) {
return apiV1Error(error);
}
}
function absoluteOptionalUrl(value: string | null | undefined): string | null {
return value ? new URL(value, `${getServerConfig().appUrl}/`).toString() : null;
}
+17
View File
@@ -0,0 +1,17 @@
import { apiV1Error, apiV1Success } from "@/server/api/v1/responses";
import { getNetaInstanceMetadata } from "@/server/api/v1/runtime";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export function GET() {
try {
return apiV1Success(getNetaInstanceMetadata(), {
headers: {
"Cache-Control": "public, max-age=60, stale-while-revalidate=300",
},
});
} catch (error) {
return apiV1Error(error);
}
}
@@ -0,0 +1,163 @@
---
title: ADR-0018 — Mobil Device Pairing ve Token Lifecycle
status: accepted-design-not-implemented
date: 2026-07-17
---
# ADR-0018 — Mobil device pairing ve token lifecycle
## Bağlam
React Native istemcisi kullanıcı tarafından girilen self-hosted Neta URL'sine bağlanacak. Web session cookie'sini kopyalamak, uzun ömürlü API key vermek veya owner şifresini cihazda sürekli saklamak güvenli bir pairing modeli değildir.
Faz 9 yalnızca discovery ve API v1 temelini yayınlar. Pairing/token endpoint'leri bu ADR uygulanmadan açılmaz.
## Karar
İlk device pairing sürümü owner cihazları için tek kullanımlık, kısa ömürlü bir pairing challenge ve DB-backed opaque token modeli kullanacaktır.
Planlanan endpoint'ler:
```text
POST /api/v1/pairing-codes
POST /api/v1/device-sessions/exchange
POST /api/v1/device-sessions/refresh
GET /api/v1/device-sessions
DELETE /api/v1/device-sessions/:id
```
### Pairing oluşturma
- Yalnızca aktif freelancer session'ı pairing oluşturabilir.
- Browser owner'dan güncel şifre veya eşdeğer step-up doğrulaması istenir.
- QR modu 256-bit rastgele secret taşır.
- Manuel giriş modu 10 karakter Crockford Base32 kod kullanır; benzer karakterler kullanılmaz.
- DB'de yalnızca HMAC/SHA-256 digest saklanır; raw secret yalnızca bir kez gösterilir.
- Challenge en fazla 5 dakika geçerlidir ve tek kullanımlıdır.
- Challenge; creator, expiry, attempt count, requested scopes ve durum içerir.
- Aynı owner için en fazla üç aktif challenge bulunabilir.
- Beş başarısız deneme challenge'ı kilitler.
- Oluşturma ve exchange IP/instance seviyesinde rate limit ve auth audit event üretir.
### Exchange
Mobil istemci şu bilgileri gönderir:
- Pairing secret/code
- Cihazda üretilmiş opaque install ID
- Kullanıcının verdiği device name
- Platform (`ios`/`android`)
- App version
- OS version'ın hassas olmayan major bilgisi
Server challenge'ı `BEGIN IMMEDIATE` transaction içinde doğrular ve tüketir. Aynı transaction device session/token family kaydını oluşturur. Başarısız exchange challenge'ı tüketmez; attempt sayısını artırır.
Owner role/scopeları server tarafından atanır. İstemci owner/user ID veya scope seçemez.
### Token modeli
- Tokenlar JWT değil, 256-bit opaque random bearer değerleridir.
- DB'de yalnızca keyed digest saklanır.
- Access token varsayılan 15 dakika geçerlidir.
- Refresh token varsayılan 30 gün geçerlidir.
- Her refresh işleminde access ve refresh token birlikte rotate edilir.
- Eski refresh token yeniden kullanılırsa token family `compromised` olur ve family içindeki tüm tokenlar atomik revoke edilir.
- Aynı cihaz için eşzamanlı refresh yarışı kısa grace/replay kaydıyla açıkça yönetilir; iki aktif refresh token bırakılmaz.
- Bearer token yalnızca `Authorization: Bearer` header'ında kabul edilir; query, URL veya log'a yazılmaz.
- Raw tokenlar API response dışında hiçbir log/audit kaydına girmez.
- React Native tokenları iOS Keychain/Android Keystore destekli secure storage'da tutar.
- İlk sürüm bearer modelidir. Device-bound public key/DPoP ayrı ADR olmadan eklenmez.
### Scope
İlk pairing yalnızca freelancer cihazı içindir. Token scope'ları explicit allowlist'tir:
```text
profile:read
clients:read
projects:read
tasks:read
calendar:read
finance:read
journal:read
```
Mutation scope'ları ilgili `/api/v1` resource endpoint'leri ve authorization testleri yayınlandıkça ayrı ayrı eklenir. `*` veya implicit admin scope kullanılmaz.
Client portal pairing'i owner pairing'inden ayrı ürün/güvenlik kararıdır; ilk implementasyona dahil değildir.
## Device token lifecycle
Durumlar:
```text
pending_pairing -> active -> expired
-> revoked
-> compromised
```
- `pending_pairing`: Challenge üretildi, token yok.
- `active`: Exchange tamamlandı ve token family kullanılabilir.
- `expired`: Refresh lifetime sona erdi.
- `revoked`: Owner, kullanıcı disable, şifre güvenlik olayı veya “tüm cihazlardan çıkış” nedeniyle kapatıldı.
- `compromised`: Refresh reuse veya güvenlik sinyali tespit edildi.
Kurallar:
- Owner dashboard'u cihaz adı, platform, oluşturulma, son kullanım ve yaklaşık IP bilgisini görür.
- Owner tek cihazı veya tüm cihazları revoke edebilir.
- Client/freelancer hesabı disable edildiğinde tüm device session'lar transaction içinde revoke edilir.
- Owner şifresi değiştiğinde varsayılan politika tüm device session'ları revoke etmektir.
- 30 gün kullanılmayan device session expire edilir.
- Son kullanım zamanı en fazla beş dakikada bir coalesce edilerek yazılır.
- Token cleanup job'u uygulama başlangıcında ve kontrollü periyotta expired kayıtları temizler; aktif request path'i toplu cleanup yapmaz.
## Backup ve restore güvenliği
Eski DB backup'ı revoke edilmiş token kayıtlarını yeniden aktif hale getirebilir. Pairing implementasyonunun release blocker'ı:
1. `instance_settings` içinde bir device token epoch tutulur.
2. Bütün token digest doğrulamaları bu epoch'a bağlanır.
3. `db:restore` başarılı atomik swap sonrasında epoch'u yeni random değerle rotate eder.
4. Böylece restore tüm eski device tokenları otomatik geçersiz kılar.
5. Owner restore sonrasında cihazları yeniden pair eder.
Bu mekanizma uygulanmadan device token endpoint'leri yayınlanamaz.
## HTTPS ve transport
- Remote pairing, exchange, refresh ve authenticated API için HTTPS zorunludur.
- Reverse proxy `X-Forwarded-Proto`/canonical origin'i doğru iletmelidir.
- HTTP yalnızca loopback/emulator geliştirme ortamında kabul edilir ve production token üretmez.
- TLS sertifika hatası kullanıcı tarafından sessizce bypass edilemez.
- Discovery API origin değiştirirse mobil istemci kullanıcı onayı olmadan credential göndermez.
## Audit ve privacy
Audit event'leri:
- `pairing_created`
- `pairing_failed`
- `pairing_consumed`
- `device_session_refreshed`
- `device_session_revoked`
- `device_token_reuse_detected`
Audit metadata raw token, pairing secret, tam IP geçmişi veya gereksiz device fingerprint içermez. Device name kullanıcı tarafından değiştirilebilir ve output-encode edilir.
## Uygulama öncesi zorunlu testler
- Pairing raw secret'ın DB/log'da bulunmaması
- Expired, consumed, locked ve brute-force challenge negatifleri
- Concurrent double exchange'de yalnızca bir başarı
- Owner/client role ve cross-owner negatifleri
- Access expiry ve refresh rotation
- Refresh reuse ile family revoke
- Disabled user ve password change revoke
- Restore sonrası token epoch invalidation
- HTTPS/loopback policy
- Tokenların URL, error ve audit output'una sızmaması
## Sonuç
Faz 9'da capability `auth.device-pairing` değeri `planned` kalır. Bu ADR'ın schema, service, rate-limit, restore epoch ve negatif test maddeleri tamamlanmadan `pairing-codes` veya `device-sessions` route'u oluşturulmaz.
@@ -2,7 +2,7 @@
title: Neta Self-Hosted v3 Ana Dönüşüm Planı title: Neta Self-Hosted v3 Ana Dönüşüm Planı
description: Supabase çıkışı, SQLite tabanlı backend, Better Auth, Poyraz UI v3 ve instance özelleştirmesi için ana yol haritası. description: Supabase çıkışı, SQLite tabanlı backend, Better Auth, Poyraz UI v3 ve instance özelleştirmesi için ana yol haritası.
status: active status: active
current_phase: "9Mobil API hazırlığı" current_phase: "10Kullanıcı yönlendirmeli sayfa tasarımları"
last_updated: 2026-07-17 last_updated: 2026-07-17
--- ---
@@ -527,7 +527,7 @@ Faz 5'in başlangıç noktası freelancer runtime'ındaki Supabase erişimleridi
- [x] Portal backend'i Faz 6 sözleşmesine göre tamamlandı. - [x] Portal backend'i Faz 6 sözleşmesine göre tamamlandı.
- [x] AI/chat ve business backend'i Faz 7 sözleşmesine göre tamamlandı. - [x] AI/chat ve business backend'i Faz 7 sözleşmesine göre tamamlandı.
- [x] Import ve runtime Supabase temizliği Faz 8'de tamamlandı. - [x] Import ve runtime Supabase temizliği Faz 8'de tamamlandı.
- [ ] Mobil API sınırı Faz 9'da tamamlandı. - [x] Mobil API sınırı Faz 9'da tamamlandı.
## 15. Revizyon güvenliği ve kota işlemi ## 15. Revizyon güvenliği ve kota işlemi
@@ -580,13 +580,13 @@ Mobil hazırlık checklist'i:
- [x] API response envelope standardı tanımlandı. - [x] API response envelope standardı tanımlandı.
- [x] API hata kodları tanımlandı. - [x] API hata kodları tanımlandı.
- [ ] API sürümleme stratejisi tanımlandı. - [x] API sürümleme stratejisi tanımlandı.
- [ ] Instance metadata sözleşmesi tanımlandı. - [x] Instance metadata sözleşmesi tanımlandı.
- [ ] Minimum desteklenen client sürümü alanı düşünüldü. - [x] Minimum desteklenen client sürümü alanı düşünüldü.
- [ ] Capability listesi sözleşmesi düşünüldü. - [x] Capability listesi sözleşmesi düşünüldü.
- [x] Service katmanı cookie/Next.js objelerine bağımlı değil. - [x] Service katmanı cookie/Next.js objelerine bağımlı değil.
- [ ] Mobil pairing ilk release kapsamı dışında tutuldu. - [x] Mobil pairing ilk release kapsamı dışında tutuldu.
- [ ] Gelecekte HTTPS zorunluluğu belgelendi. - [x] Gelecekte HTTPS zorunluluğu belgelendi.
## 17. Supabase veri import ve cutover planı ## 17. Supabase veri import ve cutover planı
@@ -744,7 +744,7 @@ Mümkün olduğunda küçük ve doğrudan test araçları tercih edilir; test al
- [x] Branding ayarları belgelendi. - [x] Branding ayarları belgelendi.
- [x] Backup/restore belgelendi. - [x] Backup/restore belgelendi.
- [x] Upgrade/migration akışı belgelendi. - [x] Upgrade/migration akışı belgelendi.
- [ ] Mobil API sınırı belgelendi. - [x] Mobil API sınırı belgelendi.
- [x] Eski ve çelişkili Supabase belgeleri archive veya kaldırıldı. - [x] Eski ve çelişkili Supabase belgeleri archive veya kaldırıldı.
- [x] ADR-0006 Poyraz UI v3 kararıyla güncellendi. - [x] ADR-0006 Poyraz UI v3 kararıyla güncellendi.
- [x] ADR-0007 ile PWA runtime durumu uyumlu hale getirildi. - [x] ADR-0007 ile PWA runtime durumu uyumlu hale getirildi.
@@ -984,11 +984,24 @@ Faz 8 tamamlanma notu (2026-07-17):
Amaç: React Native geliştirmesine başlamadan önce instance keşif ve stabil API sınırını tamamlamak. Amaç: React Native geliştirmesine başlamadan önce instance keşif ve stabil API sınırını tamamlamak.
- [ ] `/api/v1` sözleşmesi yayınlandı. - [x] `/api/v1` sözleşmesi yayınlandı.
- [ ] `/.well-known/neta` sözleşmesi yayınlandı. - [x] `/.well-known/neta` sözleşmesi yayınlandı.
- [ ] Instance metadata ve capability modeli tamamlandı. - [x] Instance metadata ve capability modeli tamamlandı.
- [ ] Pairing güvenlik tasarımı ayrı ADR olarak yazıldı. - [x] Pairing güvenlik tasarımı ayrı ADR olarak yazıldı.
- [ ] Device token lifecycle tasarlandı. - [x] Device token lifecycle tasarlandı.
Faz 9 tamamlanma notu (2026-07-17):
- `/.well-known/neta`; protocol/discovery sürümü, kalıcı instance kimliği, application adı, mutlak API/meta/health URL'leri ve HTTPS politikasını public discovery belgesi olarak yayınlar.
- `/api/v1/meta`; server/API sürümü, instance/organization kimliği, absolute branding asset URL'leri, SemVer minimum mobil sürümü, platformlar, auth yöntemi, capability durumları ve navigasyon linklerini standart success envelope içinde döndürür.
- `/api/v1/health` SQLite/migration readiness'i v1 envelope ve `SERVICE_UNAVAILABLE` hata koduyla sunar. `/api/v1/me` yalnızca geçerli Better Auth session'ıyla güvenli user/role/client bağı ve session expiry döndürür; token/password/secret içermez.
- `instance_settings` ve migration `0007`; ilk discovery isteğinde concurrency-safe oluşturulan UUID'yi backup/restore ile korunacak kalıcı instance kimliği yapar. Domain veya marka adı kimlik olarak kullanılmaz.
- API major sürümü URL'de `/api/v1` olarak kilitlendi. Additive alan/capability değişiklikleri v1 içinde; alan silme, tip/anlam/auth kırılması yeni major içinde yapılacaktır. Tüm v1 yanıtları `X-Neta-API-Version: 1` taşır.
- `NETA_MINIMUM_MOBILE_VERSION` opsiyonel SemVer alt sınırı olarak eklendi. Bilinmeyen capability/alanları yok sayma ve `planned` capability'yi kullanmama kuralı belgelendi.
- Pairing runtime'a sahte/eksik endpoint olarak eklenmedi; `auth.device-pairing` capability'si `planned` durumundadır. Ayrı ADR-0018; tek kullanımlık challenge, rate limit, hash-only secret, opaque access/refresh token rotation, reuse detection, scope, revoke/expire/compromised lifecycle, secure storage ve restore sonrası token epoch invalidation gereksinimlerini kilitler.
- Mobil URL bağlantı algoritması; origin normalizasyonu, HTTPS, redirect/downgrade koruması, discovery/meta instance ID eşlemesi ve kimlik değişiminde credential'ı sessizce kullanmama kurallarıyla `phase-9-mobile-api.md` belgesinde yayınlandı.
- `phase9:api-boundary` pairing route'larının tasarım uygulanmadan açılmadığını ve API/service sınırını doğrular. `phase9:smoke`; eşzamanlı discovery, metadata, minimum sürüm, capabilities, health, anonymous/owner/client `/me`, disabled client reddi ve absolute branding URL akışlarını gerçek Next.js + Better Auth üzerinde doğrular.
- Typecheck, hedefli ESLint, migration drift kontrolü, production build, Faz 8 source/build artifact sınırı ve `git diff --check` başarılıdır.
Çıkış kriteri: Mobil istemci backend'in iç uygulama detaylarına bağımlı olmadan entegrasyona başlayabilir. Çıkış kriteri: Mobil istemci backend'in iç uygulama detaylarına bağımlı olmadan entegrasyona başlayabilir.
@@ -1002,8 +1015,8 @@ Başlangıç koşulları:
- [x] Faz 6 portal backend geçişi tamamlandı. - [x] Faz 6 portal backend geçişi tamamlandı.
- [x] Faz 7 kapsamındaki runtime modülleri tamamlandı veya açıkça ertelendi. - [x] Faz 7 kapsamındaki runtime modülleri tamamlandı veya açıkça ertelendi.
- [x] Faz 8 Supabase runtime temizliği ve release hardening tamamlandı. - [x] Faz 8 Supabase runtime temizliği ve release hardening tamamlandı.
- [ ] Faz 9 mobil API hazırlığı tamamlandı. - [x] Faz 9 mobil API hazırlığı tamamlandı.
- [ ] Tasarım sırasında kullanılacak backend veri sözleşmeleri stabil. - [x] Tasarım sırasında kullanılacak backend veri sözleşmeleri stabil.
Sayfa grupları: Sayfa grupları:
@@ -1051,7 +1064,7 @@ Her sayfa için tasarım kabul checklist'i:
- [x] Hedef schema tamamlandı. - [x] Hedef schema tamamlandı.
- [x] Service/repository sınırı tamamlandı. - [x] Service/repository sınırı tamamlandı.
- [x] Server-side authorization tamamlandı. - [x] Server-side authorization tamamlandı.
- [ ] API v1 sınırı hazırlandı. - [x] API v1 sınırı hazırlandı.
### Supabase çıkışı ### Supabase çıkışı
@@ -0,0 +1,196 @@
---
title: Faz 9 — Mobil API ve Instance Discovery Sözleşmesi
description: React Native istemcileri için discovery, API v1, metadata, capability, sürümleme ve güvenlik sınırı.
status: completed
last_updated: 2026-07-17
---
# Faz 9 — Mobil API ve instance discovery
Bu faz mobil uygulamanın Neta backend iç yapısını bilmeden bir self-hosted instance'ı tanımasını sağlar. Mobil uygulamanın kendisi ve device pairing implementasyonu kapsam dışıdır.
## 1. Bağlantı akışı
Kullanıcı mobil uygulamaya instance URL'sini girer:
```text
https://neta.example.com
```
İstemci:
1. URL'yi yalnızca origin olacak şekilde normalize eder; credential, query ve fragment kabul etmez.
2. Production'da HTTPS olmayan remote origin'i reddeder. HTTP yalnızca `localhost`, `127.0.0.1` ve emulator geliştirme adresleri için açık kullanıcı onayıyla kullanılabilir.
3. `GET /.well-known/neta` çağrısını yapar.
4. `protocol=neta` ve desteklenen `discoveryVersion` değerini doğrular.
5. Discovery belgesindeki API URL'lerinin beklenen origin'den çıkmadığını doğrular.
6. `GET /api/v1/meta` çağrısını yapar ve `instance.id` değerini discovery `instanceId` değeriyle karşılaştırır.
7. Daha önce kaydedilmiş aynı origin farklı bir `instanceId` döndürürse bunu yeni/restore edilmiş instance olarak kullanıcıya açıkça gösterir; mevcut credential'ı sessizce yeniden kullanmaz.
8. Minimum client sürümünü ve capability listesini değerlendirir.
9. Instance kaydını `origin + instanceId + applicationName + iconUrl` ile yerel güvenli metadata alanına kaydeder.
Redirect takibi en fazla üç hop olmalı ve HTTPS'ten HTTP'ye downgrade edilmemelidir. Discovery veya meta içindeki URL kullanıcı girdisinden bağımsız güven kaynağı sayılmaz.
## 2. Endpoint özeti
| Endpoint | Auth | Cache | Amaç |
| --- | --- | --- | --- |
| `GET /.well-known/neta` | Public | 60 saniye public | Instance ve API keşfi |
| `GET /api/v1/meta` | Public | 60 saniye public | Marka, sürüm ve capability bilgisi |
| `GET /api/v1/health` | Public | No-store | DB/migration readiness |
| `GET /api/v1/me` | Better Auth session | Private/no-store | Güvenli kullanıcı/session özeti |
Public endpoint'ler session oluşturmaz ve secret dönmez. `/me` geçersiz, süresi dolmuş veya disabled hesaba ait session için `401 UNAUTHENTICATED` döndürür.
## 3. Discovery v1
Örnek:
```json
{
"protocol": "neta",
"discoveryVersion": 1,
"instanceId": "6d26e558-9c93-4cd8-93a1-5c8d5166045a",
"applicationName": "Poyraz Studio",
"api": {
"version": "1",
"baseUrl": "https://neta.example.com/api/v1",
"metaUrl": "https://neta.example.com/api/v1/meta",
"healthUrl": "https://neta.example.com/api/v1/health"
},
"security": {
"httpsRequired": true,
"insecureLoopbackAllowed": true
}
}
```
`instanceId` ilk discovery isteğinde kriptografik UUID olarak oluşturulur, `instance_settings` tablosunda saklanır ve backup/restore ile korunur. Domain veya marka adı instance kimliği değildir.
## 4. API v1 envelope
Başarılı yanıt:
```json
{
"ok": true,
"data": {}
}
```
Hata:
```json
{
"ok": false,
"error": {
"code": "UNAUTHENTICATED",
"message": "Geçerli bir oturum gerekli.",
"details": {}
}
}
```
`details` opsiyoneldir. `/api/v1` yanıtları `X-Neta-API-Version: 1` header'ı taşır. Mobil istemci kullanıcıya göstereceği metni `message` alanından alabilir; program akışını yalnızca stabil `code` üzerinden kurmalıdır.
Mevcut hata kodları:
- `VALIDATION_ERROR`
- `UNAUTHENTICATED`
- `FORBIDDEN`
- `NOT_FOUND`
- `CONFLICT`
- `INVARIANT_VIOLATION`
- `UPSTREAM_ERROR`
- `UPSTREAM_TIMEOUT`
- `SERVICE_UNAVAILABLE`
- `INTERNAL_ERROR`
## 5. Metadata ve capability
`/api/v1/meta` şu grupları döndürür:
- Protokol/discovery/API major sürümü
- Neta server package sürümü
- Kalıcı instance kimliği ve oluşturulma zamanı
- Application/organization adı
- Mobil için absolute logo/icon URL'leri ve semantic marka bilgisi
- Minimum desteklenen mobil client sürümü
- `ios` ve `android` platform listesi
- Authentication durumu
- Capability listesi
- Discovery, API, health ve me linkleri
Capability kaydı:
```json
{
"id": "portal.client",
"version": 1,
"status": "available",
"access": "client"
}
```
İstemci bilinmeyen capability ID ve alanlarını yok saymalıdır. `status=planned`, endpoint'in kullanılabilir olduğu anlamına gelmez.
İlk capability seti:
- `instance.discovery`
- `instance.branding`
- `auth.better-auth-cookie`
- `files.local`
- `freelancer.core`
- `portal.client`
- `ai.assistant`
- `auth.device-pairing``planned`
## 6. Minimum mobil sürüm
Instance sahibi opsiyonel SemVer tabanını environment ile ilan edebilir:
```env
NETA_MINIMUM_MOBILE_VERSION=1.2.0
```
Boşsa metadata `minimumSupportedVersion: null` döndürür ve server sürüm zorlaması yapmaz. İlk mobil release yayınlandığında istemci kendi sürümünü SemVer olarak karşılaştırır:
- Client sürümü minimumdan düşükse authenticated mutation başlatmaz.
- Discovery/meta/health erişimini korur.
- Kullanıcıya upgrade gereksinimini gösterir.
- Pre-release SemVer yalnızca test kanallarında kullanılmalıdır.
## 7. API sürümleme politikası
- Major sürüm URL'dedir: `/api/v1`.
- Yeni opsiyonel alan, yeni endpoint ve yeni capability additive değişikliktir; v1 içinde yapılabilir.
- Alan silme, alan tipini değiştirme, mevcut enum anlamını değiştirme veya auth modelini kırma yeni major `/api/v2` gerektirir.
- ID'ler opaque string kabul edilir; UUID formatına client iş mantığı bağlanmaz.
- Timestamp'ler UTC ISO-8601 string'dir.
- Para değerleri ilgili resource API'leri yayınlandığında integer minor unit olacaktır.
- Liste endpoint'leri yayınlandığında cursor pagination kullanacaktır; offset sözleşmesi varsayılmaz.
- İstemci bilinmeyen JSON alanlarını ve enum/capability değerlerini forward-compatible biçimde yok sayar.
- Eski major kaldırılmadan önce metadata capability ve release notlarıyla deprecation duyurulur.
## 8. Authentication sınırı
Bugünkü `/me`, Better Auth cookie session'ını doğrular; web ve entegrasyon smoke testleri aynı güvenli session adapter'ını kullanır. Session token, password hash veya AI secret yanıt içine girmez.
React Native için kalıcı bearer/device session üretimi bu fazda uygulanmadı. Mobil istemci `auth.device-pairing.status=planned` gördüğünde pairing UI'ını etkinleştirmemelidir. Gelecek güvenlik ve lifecycle kararı [ADR-0018](adr-0018-device-pairing.md) belgesindedir.
## 9. Test ve kalite kapısı
`pnpm phase9:smoke` şunları gerçek Next.js ve Better Auth akışında doğrular:
- Eşzamanlı discovery isteklerinde tek ve kalıcı instance ID
- Discovery/meta ID ve absolute URL tutarlılığı
- Public cache ve session oluşturmama davranışı
- API version header ve envelope
- Readiness health
- Minimum client sürümü ve capability modeli
- Anonymous `/me` negatif testi
- Freelancer `/me`
- Client `/me` ve client kimlik bağı
- Disabled client session reddi
- Branding değişikliğinin absolute mobile metadata'ya yansıması
- Pairing route'larının implementasyon tamamlanmadan yayınlanmaması
+3 -1
View File
@@ -28,7 +28,9 @@
"phase7:backend-boundary": "node scripts/phase7-backend-boundary.mjs", "phase7:backend-boundary": "node scripts/phase7-backend-boundary.mjs",
"phase7:smoke": "node scripts/phase7-smoke.mjs", "phase7:smoke": "node scripts/phase7-smoke.mjs",
"phase8:import-smoke": "node scripts/phase8-import-smoke.mjs", "phase8:import-smoke": "node scripts/phase8-import-smoke.mjs",
"phase8:release-boundary": "node scripts/phase8-release-boundary.mjs" "phase8:release-boundary": "node scripts/phase8-release-boundary.mjs",
"phase9:api-boundary": "node scripts/phase9-api-boundary.mjs",
"phase9:smoke": "node scripts/phase9-smoke.mjs"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/google": "^3.0.80", "@ai-sdk/google": "^3.0.80",
+118
View File
@@ -21,6 +21,7 @@ const env = {
NEXT_PUBLIC_SITE_URL: baseUrl, NEXT_PUBLIC_SITE_URL: baseUrl,
BETTER_AUTH_SECRET: "phase1-auth-smoke-secret-is-longer-than-32-characters", BETTER_AUTH_SECRET: "phase1-auth-smoke-secret-is-longer-than-32-characters",
TRUSTED_ORIGINS: baseUrl, TRUSTED_ORIGINS: baseUrl,
NETA_MINIMUM_MOBILE_VERSION: "1.2.3-smoke.1",
NEXT_TELEMETRY_DISABLED: "1", NEXT_TELEMETRY_DISABLED: "1",
}; };
@@ -53,6 +54,79 @@ server.stderr.on("data", (chunk) => {
try { try {
await waitForServer(); await waitForServer();
const discoveryResponses = await Promise.all(
Array.from({ length: 4 }, () => fetch(`${baseUrl}/.well-known/neta`)),
);
const discoveryDocuments = await Promise.all(
discoveryResponses.map(async (response) => {
assert.equal(response.status, 200, "Neta discovery must be public");
assert.match(
response.headers.get("cache-control") ?? "",
/public/,
"Discovery must declare its public cache policy",
);
assert.equal(response.headers.get("set-cookie"), null, "Discovery must not create a session");
return response.json();
}),
);
const discovery = discoveryDocuments[0];
assert.equal(discovery.protocol, "neta");
assert.equal(discovery.discoveryVersion, 1);
assert.match(discovery.instanceId, /^[0-9a-f-]{36}$/i);
assert.equal(discovery.api.version, "1");
assert.equal(discovery.api.baseUrl, `${baseUrl}/api/v1`);
assert.equal(discovery.api.metaUrl, `${baseUrl}/api/v1/meta`);
assert.equal(discovery.security.httpsRequired, true);
assert.deepEqual(
new Set(discoveryDocuments.map((document) => document.instanceId)),
new Set([discovery.instanceId]),
"Concurrent discovery must return one stable instance id",
);
const publicMeta = await jsonRequest("/api/v1/meta");
assert.equal(publicMeta.response.status, 200);
assert.equal(publicMeta.response.headers.get("x-neta-api-version"), "1");
assert.equal(publicMeta.payload.ok, true);
assert.equal(publicMeta.payload.data.instance.id, discovery.instanceId);
assert.match(
publicMeta.payload.data.instance.createdAt,
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
"Instance timestamps must use UTC ISO-8601",
);
assert.equal(publicMeta.payload.data.client.minimumSupportedVersion, "1.2.3-smoke.1");
assert.equal(publicMeta.payload.data.links.me, `${baseUrl}/api/v1/me`);
assert.deepEqual(publicMeta.payload.data.client.platforms, ["ios", "android"]);
assert.deepEqual(
publicMeta.payload.data.capabilities.find(
(capability) => capability.id === "auth.device-pairing",
),
{
id: "auth.device-pairing",
version: 1,
status: "planned",
access: "freelancer",
},
);
const publicV1Health = await jsonRequest("/api/v1/health");
assert.equal(publicV1Health.response.status, 200);
assert.deepEqual(
{
ok: publicV1Health.payload.ok,
status: publicV1Health.payload.data.status,
migrationsApplied: publicV1Health.payload.data.checks.migrationsApplied,
},
{ ok: true, status: "ok", migrationsApplied: true },
);
const anonymousMe = await jsonRequest("/api/v1/me");
assert.equal(anonymousMe.response.status, 401);
assert.equal(anonymousMe.response.headers.get("x-neta-api-version"), "1");
assert.deepEqual(
{ ok: anonymousMe.payload.ok, code: anonymousMe.payload.error.code },
{ ok: false, code: "UNAUTHENTICATED" },
);
const setupAttempts = await Promise.all([ const setupAttempts = await Promise.all([
authPost("/api/auth/sign-up/email", { authPost("/api/auth/sign-up/email", {
name: "Owner One", name: "Owner One",
@@ -83,6 +157,32 @@ try {
const ownerUserId = db const ownerUserId = db
.prepare("select auth_user_id as authUserId from app_profiles where role = 'freelancer'") .prepare("select auth_user_id as authUserId from app_profiles where role = 'freelancer'")
.get().authUserId; .get().authUserId;
assert.deepEqual(
db.prepare("select instance_id as instanceId from instance_settings").all(),
[{ instanceId: discovery.instanceId }],
"Discovery identity must be persisted exactly once",
);
const ownerMe = await jsonRequest("/api/v1/me", { cookie: ownerCookie });
assert.equal(ownerMe.response.status, 200);
assert.deepEqual(
{
id: ownerMe.payload.data.user.id,
email: ownerMe.payload.data.user.email,
role: ownerMe.payload.data.user.role,
clientId: ownerMe.payload.data.user.clientId,
},
{
id: ownerUserId,
email: ownerEmail,
role: "freelancer",
clientId: null,
},
);
assert.doesNotMatch(
JSON.stringify(ownerMe.payload),
/token|password/i,
"The me contract must not expose session tokens or password material",
);
const insertClient = db.prepare( const insertClient = db.prepare(
"insert into clients (id, owner_user_id, name) values (?, ?, ?)", "insert into clients (id, owner_user_id, name) values (?, ?, ?)",
); );
@@ -257,6 +357,13 @@ try {
assert.match(brandedLoginHtml, /--poyraz-primary:#336699/, "Brand tokens must be present in first HTML response"); assert.match(brandedLoginHtml, /--poyraz-primary:#336699/, "Brand tokens must be present in first HTML response");
const dynamicManifest = await (await fetch(`${baseUrl}/manifest.webmanifest`)).json(); const dynamicManifest = await (await fetch(`${baseUrl}/manifest.webmanifest`)).json();
assert.equal(dynamicManifest.name, "Neta Smoke Studio", "Manifest must use instance branding"); assert.equal(dynamicManifest.name, "Neta Smoke Studio", "Manifest must use instance branding");
const brandedMeta = await jsonRequest("/api/v1/meta");
assert.equal(brandedMeta.payload.data.instance.applicationName, "Neta Smoke Studio");
assert.equal(
brandedMeta.payload.data.branding.lightLogoUrl,
`${baseUrl}/api/branding/assets/${logoFileId}`,
"Mobile metadata must expose absolute branding asset URLs",
);
const publicLogo = await fetch(`${baseUrl}/api/branding/assets/${logoFileId}`); const publicLogo = await fetch(`${baseUrl}/api/branding/assets/${logoFileId}`);
assert.equal(publicLogo.status, 200, "Referenced branding asset must be publicly readable"); assert.equal(publicLogo.status, 200, "Referenced branding asset must be publicly readable");
assert.equal(publicLogo.headers.get("x-content-type-options"), "nosniff"); assert.equal(publicLogo.headers.get("x-content-type-options"), "nosniff");
@@ -371,6 +478,15 @@ try {
}); });
assert.equal(clientSignIn.response.ok, true, JSON.stringify(clientSignIn.payload)); assert.equal(clientSignIn.response.ok, true, JSON.stringify(clientSignIn.payload));
const clientCookie = cookieHeader(clientSignIn.response); const clientCookie = cookieHeader(clientSignIn.response);
const clientMe = await jsonRequest("/api/v1/me", { cookie: clientCookie });
assert.equal(clientMe.response.status, 200);
assert.deepEqual(
{
role: clientMe.payload.data.user.role,
clientId: clientMe.payload.data.user.clientId,
},
{ role: "client", clientId: "client-alpha" },
);
for (const [pathname, body] of [ for (const [pathname, body] of [
["/api/finance-analysis", undefined], ["/api/finance-analysis", undefined],
@@ -477,6 +593,8 @@ try {
headers: { cookie: clientCookie }, headers: { cookie: clientCookie },
}); });
assert.equal((await revokedSession.json()), null, "Disabling a client must revoke active sessions"); assert.equal((await revokedSession.json()), null, "Disabling a client must revoke active sessions");
const revokedClientMe = await jsonRequest("/api/v1/me", { cookie: clientCookie });
assert.equal(revokedClientMe.response.status, 401, "Disabled client API session must be rejected");
const disabledSignIn = await authPost("/api/auth/sign-in/email", { const disabledSignIn = await authPost("/api/auth/sign-in/email", {
email: "client@example.com", email: "client@example.com",
+75
View File
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const repoRoot = process.cwd();
const requiredRoutes = [
"app/.well-known/neta/route.ts",
"app/api/v1/meta/route.ts",
"app/api/v1/health/route.ts",
"app/api/v1/me/route.ts",
];
for (const route of requiredRoutes) {
assert.ok(fs.existsSync(path.join(repoRoot, route)), `Missing Phase 9 route: ${route}`);
}
const contracts = read("server/api/v1/contracts.ts");
for (const value of [
'NETA_PROTOCOL = "neta"',
"NETA_DISCOVERY_VERSION = 1",
'NETA_API_VERSION = "1"',
'"auth.device-pairing"',
'status: "planned"',
"minimumSupportedVersion",
]) {
assert.ok(contracts.includes(value), `Missing API contract marker: ${value}`);
}
const instanceService = read("server/instance/service.ts");
assert.doesNotMatch(
instanceService,
/next\/|Request\b|Response\b|cookies?\b|headers?\b/i,
"Instance service must stay independent from Next.js transport objects",
);
const discovery = read("app/.well-known/neta/route.ts");
assert.doesNotMatch(
discovery,
/getSession|requireSession|authorization/i,
"Discovery must remain public and session-independent",
);
for (const route of requiredRoutes.slice(1)) {
const content = read(route);
assert.match(content, /apiV1(?:Success|Error)/, `${route} must use the v1 envelope`);
}
for (const futureRoute of [
"app/api/v1/pairing-codes",
"app/api/v1/device-sessions",
]) {
assert.equal(
fs.existsSync(path.join(repoRoot, futureRoute)),
false,
`${futureRoute} must not ship before the pairing security design is implemented`,
);
}
const runtimeFiles = [
...requiredRoutes,
"server/api/v1/contracts.ts",
"server/api/v1/responses.ts",
"server/api/v1/runtime.ts",
"server/instance/service.ts",
"server/instance/runtime.ts",
"server/repositories/instance.ts",
];
for (const file of runtimeFiles) {
assert.doesNotMatch(read(file), /@supabase\/|supabase\.co/i, `Supabase reference in ${file}`);
}
console.log("Phase 9 API boundary passed: discovery, v1 contracts and pairing scope verified.");
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
+10
View File
@@ -0,0 +1,10 @@
import { execFileSync } from "node:child_process";
for (const [command, args] of [
[process.execPath, ["scripts/phase9-api-boundary.mjs"]],
[process.execPath, ["scripts/phase1-auth-smoke.mjs"]],
]) {
execFileSync(command, args, { cwd: process.cwd(), stdio: "inherit" });
}
console.log("Phase 9 mobile API smoke passed.");
+171
View File
@@ -0,0 +1,171 @@
import type { PublicBranding } from "../../branding/service";
import type { InstanceIdentity } from "../../instance/service";
export const NETA_PROTOCOL = "neta" as const;
export const NETA_DISCOVERY_VERSION = 1 as const;
export const NETA_API_VERSION = "1" as const;
export const NETA_API_BASE_PATH = "/api/v1" as const;
export type CapabilityStatus = "available" | "planned";
export type CapabilityAccess = "public" | "session" | "freelancer" | "client";
export type NetaCapability = {
id: string;
version: number;
status: CapabilityStatus;
access: CapabilityAccess;
};
export const NETA_CAPABILITIES = [
{ id: "instance.discovery", version: 1, status: "available", access: "public" },
{ id: "instance.branding", version: 1, status: "available", access: "public" },
{ id: "auth.better-auth-cookie", version: 1, status: "available", access: "session" },
{ id: "files.local", version: 1, status: "available", access: "session" },
{ id: "freelancer.core", version: 1, status: "available", access: "freelancer" },
{ id: "portal.client", version: 1, status: "available", access: "client" },
{ id: "ai.assistant", version: 1, status: "available", access: "freelancer" },
{ id: "auth.device-pairing", version: 1, status: "planned", access: "freelancer" },
] as const satisfies readonly NetaCapability[];
export type NetaDiscoveryDocument = {
protocol: typeof NETA_PROTOCOL;
discoveryVersion: typeof NETA_DISCOVERY_VERSION;
instanceId: string;
applicationName: string;
api: {
version: typeof NETA_API_VERSION;
baseUrl: string;
metaUrl: string;
healthUrl: string;
};
security: {
httpsRequired: true;
insecureLoopbackAllowed: true;
};
};
export type NetaInstanceMetadata = {
protocol: {
name: typeof NETA_PROTOCOL;
discoveryVersion: typeof NETA_DISCOVERY_VERSION;
apiVersion: typeof NETA_API_VERSION;
};
server: {
version: string;
};
instance: {
id: string;
createdAt: string;
applicationName: string;
shortName: string;
organizationName: string | null;
};
branding: {
primaryColor: string;
accentColor: string;
defaultColorMode: PublicBranding["defaultColorMode"];
radiusScale: PublicBranding["radiusScale"];
lightLogoUrl: string | null;
darkLogoUrl: string | null;
iconUrl: string | null;
};
client: {
minimumSupportedVersion: string | null;
platforms: readonly ["ios", "android"];
};
authentication: {
sessionMethod: "better-auth-cookie";
devicePairing: "planned";
};
capabilities: readonly NetaCapability[];
links: {
discovery: string;
apiBase: string;
health: string;
me: string;
};
};
type ContractInput = {
appUrl: string;
serverVersion: string;
minimumMobileClientVersion: string | null;
identity: InstanceIdentity;
branding: PublicBranding;
};
export function buildDiscoveryDocument(
input: ContractInput,
): NetaDiscoveryDocument {
const apiBaseUrl = absoluteUrl(input.appUrl, NETA_API_BASE_PATH);
return {
protocol: NETA_PROTOCOL,
discoveryVersion: NETA_DISCOVERY_VERSION,
instanceId: input.identity.instanceId,
applicationName: input.branding.applicationName,
api: {
version: NETA_API_VERSION,
baseUrl: apiBaseUrl,
metaUrl: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/meta`),
healthUrl: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/health`),
},
security: {
httpsRequired: true,
insecureLoopbackAllowed: true,
},
};
}
export function buildInstanceMetadata(
input: ContractInput,
): NetaInstanceMetadata {
return {
protocol: {
name: NETA_PROTOCOL,
discoveryVersion: NETA_DISCOVERY_VERSION,
apiVersion: NETA_API_VERSION,
},
server: {
version: input.serverVersion,
},
instance: {
id: input.identity.instanceId,
createdAt: input.identity.createdAt,
applicationName: input.branding.applicationName,
shortName: input.branding.shortName,
organizationName: input.branding.organizationName,
},
branding: {
primaryColor: input.branding.primaryColor,
accentColor: input.branding.accentColor,
defaultColorMode: input.branding.defaultColorMode,
radiusScale: input.branding.radiusScale,
lightLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.lightLogoUrl),
darkLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.darkLogoUrl),
iconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
},
client: {
minimumSupportedVersion: input.minimumMobileClientVersion,
platforms: ["ios", "android"],
},
authentication: {
sessionMethod: "better-auth-cookie",
devicePairing: "planned",
},
capabilities: NETA_CAPABILITIES,
links: {
discovery: absoluteUrl(input.appUrl, "/.well-known/neta"),
apiBase: absoluteUrl(input.appUrl, NETA_API_BASE_PATH),
health: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/health`),
me: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/me`),
},
};
}
function absoluteOptionalUrl(baseUrl: string, value: string | null): string | null {
return value ? absoluteUrl(baseUrl, value) : null;
}
function absoluteUrl(baseUrl: string, pathname: string): string {
return new URL(pathname, `${baseUrl}/`).toString();
}
+24
View File
@@ -0,0 +1,24 @@
import "server-only";
import type { NextResponse } from "next/server";
import { apiError, apiSuccess } from "../responses";
import { NETA_API_VERSION } from "./contracts";
export function apiV1Success<T>(
data: T,
init?: ResponseInit,
): NextResponse {
return withV1Headers(apiSuccess(data, init));
}
export function apiV1Error(error: unknown): NextResponse {
return withV1Headers(apiError(error));
}
function withV1Headers(response: NextResponse): NextResponse {
response.headers.set("X-Neta-API-Version", NETA_API_VERSION);
if (!response.headers.has("Cache-Control")) {
response.headers.set("Cache-Control", "private, no-store");
}
return response;
}
+29
View File
@@ -0,0 +1,29 @@
import "server-only";
import packageJson from "../../../package.json";
import { getPublicBranding } from "../../branding/runtime";
import { getServerConfig } from "../../config";
import { getInstanceService } from "../../instance/runtime";
import {
buildDiscoveryDocument,
buildInstanceMetadata,
} from "./contracts";
export function getNetaDiscoveryDocument() {
return buildDiscoveryDocument(getContractInput());
}
export function getNetaInstanceMetadata() {
return buildInstanceMetadata(getContractInput());
}
function getContractInput() {
const config = getServerConfig();
return {
appUrl: config.appUrl,
serverVersion: packageJson.version,
minimumMobileClientVersion: config.minimumMobileClientVersion,
identity: getInstanceService().getIdentity(),
branding: getPublicBranding(),
};
}
+7
View File
@@ -16,6 +16,11 @@ const envSchema = z.object({
DATABASE_PATH: z.string().trim().optional(), DATABASE_PATH: z.string().trim().optional(),
OLLAMA_BASE_URL: z.string().url().optional(), OLLAMA_BASE_URL: z.string().url().optional(),
AI_REQUEST_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(120_000).optional(), AI_REQUEST_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(120_000).optional(),
NETA_MINIMUM_MOBILE_VERSION: z
.string()
.trim()
.regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/)
.optional(),
}); });
export type ServerConfig = { export type ServerConfig = {
@@ -31,6 +36,7 @@ export type ServerConfig = {
betterAuthSecret?: string; betterAuthSecret?: string;
ollamaBaseUrl: string; ollamaBaseUrl: string;
aiRequestTimeoutMs: number; aiRequestTimeoutMs: number;
minimumMobileClientVersion: string | null;
}; };
let cachedConfig: ServerConfig | undefined; let cachedConfig: ServerConfig | undefined;
@@ -82,6 +88,7 @@ export function getServerConfig(): ServerConfig {
betterAuthSecret, betterAuthSecret,
ollamaBaseUrl: parsed.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434/v1", ollamaBaseUrl: parsed.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434/v1",
aiRequestTimeoutMs: parsed.AI_REQUEST_TIMEOUT_MS ?? 30_000, aiRequestTimeoutMs: parsed.AI_REQUEST_TIMEOUT_MS ?? 30_000,
minimumMobileClientVersion: parsed.NETA_MINIMUM_MOBILE_VERSION ?? null,
}; };
return cachedConfig; return cachedConfig;
@@ -0,0 +1,8 @@
CREATE TABLE `instance_settings` (
`key` text PRIMARY KEY NOT NULL,
`instance_id` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `instance_settings_instance_id_unique` ON `instance_settings` (`instance_id`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -50,6 +50,13 @@
"when": 1784266217938, "when": 1784266217938,
"tag": "0006_moaning_kitty_pryde", "tag": "0006_moaning_kitty_pryde",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1784268984532,
"tag": "0007_flaky_kinsey_walden",
"breakpoints": true
} }
] ]
} }
+7
View File
@@ -4,6 +4,13 @@ import { user } from "./auth";
export type AiProvider = "gemini" | "openai" | "groq" | "ollama"; export type AiProvider = "gemini" | "openai" | "groq" | "ollama";
export const instanceSettings = sqliteTable("instance_settings", {
key: text("key").primaryKey(),
instanceId: text("instance_id").notNull().unique(),
createdAt: text("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
updatedAt: text("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
});
export const userAiSettings = sqliteTable( export const userAiSettings = sqliteTable(
"user_ai_settings", "user_ai_settings",
{ {
+3 -1
View File
@@ -6,7 +6,8 @@ export type DomainErrorCode =
| "CONFLICT" | "CONFLICT"
| "INVARIANT_VIOLATION" | "INVARIANT_VIOLATION"
| "UPSTREAM_ERROR" | "UPSTREAM_ERROR"
| "UPSTREAM_TIMEOUT"; | "UPSTREAM_TIMEOUT"
| "SERVICE_UNAVAILABLE";
const statusByCode: Record<DomainErrorCode, number> = { const statusByCode: Record<DomainErrorCode, number> = {
VALIDATION_ERROR: 400, VALIDATION_ERROR: 400,
@@ -17,6 +18,7 @@ const statusByCode: Record<DomainErrorCode, number> = {
INVARIANT_VIOLATION: 422, INVARIANT_VIOLATION: 422,
UPSTREAM_ERROR: 502, UPSTREAM_ERROR: 502,
UPSTREAM_TIMEOUT: 504, UPSTREAM_TIMEOUT: 504,
SERVICE_UNAVAILABLE: 503,
}; };
export class DomainError extends Error { export class DomainError extends Error {
+8
View File
@@ -0,0 +1,8 @@
import "server-only";
import { getSqliteConnection } from "../db/client";
import { InstanceService } from "./service";
export function getInstanceService(): InstanceService {
return new InstanceService(getSqliteConnection().db);
}
+54
View File
@@ -0,0 +1,54 @@
import { randomUUID } from "node:crypto";
import type { DomainDatabase } from "../domain/database";
import { DomainError } from "../domain/errors";
import { createInstanceRepository } from "../repositories/instance";
export type InstanceIdentity = {
instanceId: string;
createdAt: string;
};
export class InstanceService {
private readonly repository;
constructor(private readonly db: DomainDatabase) {
this.repository = createInstanceRepository(db);
}
getIdentity(): InstanceIdentity {
const existing = this.repository.get();
if (existing) return toIdentity(existing);
this.repository.createIfMissing(randomUUID());
const created = this.repository.get();
if (!created) {
throw new DomainError(
"INVARIANT_VIOLATION",
"Instance kimliği oluşturulamadı.",
);
}
return toIdentity(created);
}
}
function toIdentity(value: {
instanceId: string;
createdAt: string;
}): InstanceIdentity {
return {
instanceId: value.instanceId,
createdAt: sqliteTimestampToIso(value.createdAt),
};
}
function sqliteTimestampToIso(value: string): string {
const normalized = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
const timestamp = new Date(normalized);
if (Number.isNaN(timestamp.getTime())) {
throw new DomainError(
"INVARIANT_VIOLATION",
"Instance oluşturulma zamanı geçersiz.",
);
}
return timestamp.toISOString();
}
+22
View File
@@ -0,0 +1,22 @@
import { eq } from "drizzle-orm";
import { instanceSettings } from "../db/schema";
import type { DomainDatabase } from "../domain/database";
const INSTANCE_SETTINGS_KEY = "default";
export function createInstanceRepository(db: DomainDatabase) {
return {
get: () =>
db
.select()
.from(instanceSettings)
.where(eq(instanceSettings.key, INSTANCE_SETTINGS_KEY))
.get(),
createIfMissing: (instanceId: string) =>
db
.insert(instanceSettings)
.values({ key: INSTANCE_SETTINGS_KEY, instanceId })
.onConflictDoNothing({ target: instanceSettings.key })
.run(),
};
}