feat(api): complete phase 9 mobile contracts
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: "9 — Mobil API hazırlığı"
|
current_phase: "10 — Kullanı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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user