Efren Yevale Varela 1 mesiac pred
rodič
commit
e83d6bd411

+ 3 - 0
.gitignore

@@ -21,3 +21,6 @@ Thumbs.db
 # Vite
 vite.config.js.timestamp-*
 vite.config.ts.timestamp-*
+# Paraglide
+src/lib/paraglide
+project.inlang/cache/

+ 195 - 0
docs/QUICK-REFERENCE.md

@@ -0,0 +1,195 @@
+# AWS Bedrock LLM Proxy - Quick Reference
+
+## 🎯 WHAT YOU'RE BUILDING
+
+An OpenAI v1 API-compatible LLM proxy that routes requests to AWS Bedrock, enforces per-user monthly spending limits, and uses OAuth 2.1 + optional legacy Bearer tokens for authentication.
+
+## 🏗️ ARCHITECTURE AT A GLANCE
+
+```
+Client (OpenAI SDK)
+  ↓
+[POST /v1/chat/completions with Bearer token]
+  ↓
+[Auth Middleware] → Detect token type (JWT vs opaque)
+  ↓
+[OAuth Validator OR Introspection]
+  ↓
+[Spending Check] → Estimate cost, verify budget
+  ↓
+[AWS Bedrock InvokeModel]
+  ↓
+[Atomic Deduction] → PostgreSQL transaction
+  ↓
+[OpenAI-formatted Response]
+```
+
+## ⚙️ CONFIGURATION
+
+**Config File**: `/run/secrets/bedrock-proxy-config.json` (mounted as Docker secret)
+
+```json
+{
+  "oauth": {
+    "discovery_url": "https://data.titleproject.space/.well-known/oauth-authorization-server"
+  },
+  "bedrock": {
+    "aws_region": "us-east-1",
+    "aws_access_key_id": "...",
+    "aws_secret_access_key": "..."
+  },
+  "database": {
+    "url": "postgresql://user:pass@db:5432/bedrock_proxy"
+  },
+  "spending": {
+    "default_limit_usd": null
+  }
+}
+```
+
+**That's it!** OAuth metadata auto-fetches from discovery URL. Zero manual configuration.
+
+## 🔐 DUAL AUTH (Both supported on same endpoint)
+
+### Option 1: OAuth 2.1 JWT (Recommended)
+```bash
+curl -X POST http://localhost:3000/api/v1/chat/completions \
+  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
+  -d '{"model":"claude-3-haiku-20240307","messages":[...]}'
+```
+
+### Option 2: Legacy Bearer (Opaque Token)
+```bash
+curl -X POST http://localhost:3000/api/v1/chat/completions \
+  -H "Authorization: Bearer abc123def456ghi..." \
+  -d '{"model":"claude-3-haiku-20240307","messages":[...]}'
+```
+
+**Detection**: Automatic. If token has 3 dots → JWT. Otherwise → opaque Bearer.
+
+## 📡 OPENAI API ENDPOINTS
+
+### POST /v1/chat/completions
+```json
+{
+  "model": "claude-3-haiku-20240307",
+  "messages": [
+    {"role": "user", "content": "Hello"}
+  ],
+  "stream": false
+}
+```
+
+### GET /v1/models
+Returns list of supported Bedrock models with native names.
+
+## 💰 SPENDING LIMITS
+
+- **Default**: Unlimited (no cap)
+- **Per-user**: Admin can set custom cap (e.g., $50/month)
+- **Monthly**: Resets 1st of month, midnight UTC
+- **Enforcement**: 
+  - Pre-flight check: Estimate cost, allow if budget remains
+  - Post-flight deduction: Actual tokens deducted after Bedrock call
+  - 1-request overage: User can exceed limit by ~1 request (~$1-5 max)
+
+## 📊 DATABASE SCHEMA
+
+```sql
+-- 3 core tables
+users (user_id, email, monthly_limit_usd, current_spend_usd, spend_reset_at)
+spending_records (user_id, model_id, input_tokens, output_tokens, cost_usd)
+bedrock_models (model_id, provider, input_price_usd, output_price_usd)
+```
+
+## 🚀 PHASE 1 DELIVERABLE (3 days)
+
+Working curl endpoint:
+```bash
+curl -X POST http://localhost:3000/api/v1/chat/completions \
+  -H "Authorization: Bearer <JWT_or_Bearer_token>" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "model": "claude-3-haiku-20240307",
+    "messages": [{"role": "user", "content": "Hello"}]
+  }'
+
+# Response
+{
+  "id": "bedrock-1704067200000",
+  "object": "chat.completion",
+  "model": "claude-3-haiku-20240307",
+  "choices": [{
+    "message": {"content": "Hello! How can I help?"},
+    "finish_reason": "stop"
+  }],
+  "usage": {
+    "prompt_tokens": 10,
+    "completion_tokens": 8,
+    "total_tokens": 18
+  }
+}
+```
+
+## 📋 KEY FILES
+
+| File | Purpose |
+|------|---------|
+| `src/lib/config/loader.js` | Load config, fetch OAuth discovery |
+| `src/lib/auth/middleware.js` | Token detection + validation |
+| `src/hooks.server.js` | SvelteKit auth integration |
+| `src/lib/bedrock/client.js` | AWS SDK wrapper |
+| `src/lib/spending/enforcer.js` | Cost estimation + atomic deduction |
+| `src/routes/api/v1/chat/completions/+server.js` | Main endpoint |
+| `src/routes/api/v1/models/+server.js` | Model list endpoint |
+
+## ✅ PRE-FLIGHT CHECKLIST
+
+Before coding:
+- [ ] AWS IAM user created (bedrock:InvokeModel permission)
+- [ ] PostgreSQL database accessible
+- [ ] OAuth discovery URL working: curl https://data.titleproject.space/.well-known/oauth-authorization-server
+- [ ] title-graphql introspection endpoint: GET /oauth/introspect
+
+## 🎯 PHASE 1 SUCCESS CRITERIA
+
+- [x] JWT and opaque Bearer tokens both work
+- [x] Spending deducted correctly from database
+- [x] OpenAI response format exact match
+- [x] Curl tests pass (no UI needed in Phase 1)
+
+## 📝 ERROR RESPONSES (All in OpenAI format)
+
+```json
+{
+  "error": {
+    "code": "authentication_error",
+    "message": "Invalid or expired token",
+    "type": "authentication_error"
+  }
+}
+```
+
+Common codes:
+- `authentication_error` (401): Bad/missing token
+- `invalid_request_error` (400): Bad parameters
+- `insufficient_credits` (429): Budget exceeded
+- `model_not_found_error` (404): Unsupported model
+- `server_error` (500): Bedrock error
+
+## 🔗 RELATED REPOS
+
+- **title-graphql**: OAuth service (introspection endpoint)
+- **testing-proxy**: SvelteKit host project (this repo)
+
+## 📚 DOCUMENTATION & NAVIGATION
+
+- **📋 Index** → [README.md](README.md)
+- **📝 Full Plan** → [bedrock-proxy-implementation-plan.md](bedrock-proxy-implementation-plan.md)
+- **✅ Checklist** → [implementation-checklist.md](implementation-checklist.md)
+
+## QUICK LINKS
+
+- **"What should I build?"** → [implementation-checklist.md](implementation-checklist.md)
+- **"How does auth work?"** → [bedrock-proxy-implementation-plan.md](bedrock-proxy-implementation-plan.md#part-3-dual-auth-middleware)
+- **"Show me the code"** → [bedrock-proxy-implementation-plan.md](bedrock-proxy-implementation-plan.md#part-4-openai-v1-api-implementation)

+ 140 - 0
docs/README.md

@@ -0,0 +1,140 @@
+# AWS Bedrock LLM Proxy - Documentation Index
+
+## Quick Start
+
+**New to this project?** Start here:
+1. Read [QUICK-REFERENCE.md](QUICK-REFERENCE.md) — 5-minute overview
+2. Check [implementation-checklist.md](implementation-checklist.md) — What to build, phase by phase
+3. Dive into [bedrock-proxy-implementation-plan.md](bedrock-proxy-implementation-plan.md) — Complete technical details
+
+---
+
+## Documentation Files
+
+### 1. [QUICK-REFERENCE.md](QUICK-REFERENCE.md)
+**Purpose**: High-level architecture overview & quick answers
+- What you're building (1 sentence)
+- Architecture diagram
+- Config structure
+- Dual auth examples
+- OpenAI endpoints
+- Spending limits
+- Key files list
+- Pre-flight checklist
+- Error responses
+
+**Read this first** — 10 minutes
+
+---
+
+### 2. [implementation-checklist.md](implementation-checklist.md)
+**Purpose**: Task breakdown & day-by-day progress tracking
+- Phase 1 (MVP) — Days 1-3
+- Phase 2 (Multi-model) — Days 4-6
+- Phase 3 (Admin) — Optional
+- Key technical tasks
+- QA matrix
+- Config requirements
+
+**Use this to track progress** — 5 minutes to skim, refer back daily
+
+---
+
+### 3. [bedrock-proxy-implementation-plan.md](bedrock-proxy-implementation-plan.md)
+**Purpose**: Complete implementation guide with code examples
+- Part 1: Architecture overview
+- Part 2: Configuration system (Docker secret, OAuth discovery)
+- Part 3: Dual auth middleware (JWT + Bearer detection)
+- Part 4: OpenAI v1 API implementation
+- Part 5: Spending enforcement
+- Part 6: Database schema
+- Part 7: Implementation phases
+- Part 8: Testing & QA
+
+**Use this while coding** — Reference as needed
+
+---
+
+## Architecture Decision Record
+
+See `../AGENTS.md` for:
+- Final architecture decisions (all 10 requirements)
+- Configuration approach
+- OAuth discovery integration
+- Dual auth implementation
+- OpenAI compatibility decisions
+
+---
+
+## Key Files to Create (Phase 1)
+
+```
+src/
+├── lib/
+│   ├── config/
+│   │   └── loader.js          ← Load config + fetch OAuth discovery
+│   ├── auth/
+│   │   └── middleware.js       ← Token detection + validation
+│   ├── bedrock/
+│   │   ├── client.js           ← AWS SDK wrapper
+│   │   └── models.js           ← Model registry + pricing
+│   └── spending/
+│       └── enforcer.js         ← Cost estimation + atomic deduction
+├── routes/
+│   └── api/v1/
+│       ├── chat/completions/
+│       │   └── +server.js      ← Main endpoint (POST)
+│       └── models/
+│           └── +server.js      ← Model list (GET)
+└── hooks.server.js             ← Auth middleware integration
+```
+
+---
+
+## Before You Start
+
+✅ Prerequisites:
+- [ ] AWS IAM user with `bedrock:InvokeModel` permission
+- [ ] PostgreSQL database
+- [ ] Verify OAuth discovery: `curl https://data.titleproject.space/.well-known/oauth-authorization-server`
+- [ ] title-graphql introspection endpoint working
+
+---
+
+## Phase 1 Success Criteria
+
+When complete, this should work:
+
+```bash
+curl -X POST http://localhost:3000/api/v1/chat/completions \
+  -H "Authorization: Bearer <JWT_or_Bearer_token>" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "model": "claude-3-haiku-20240307",
+    "messages": [{"role": "user", "content": "Hello"}]
+  }'
+
+# Returns OpenAI-format response with usage tokens
+```
+
+---
+
+## Getting Help
+
+- **"What should I build?"** → [implementation-checklist.md](implementation-checklist.md)
+- **"How does auth work?"** → [bedrock-proxy-implementation-plan.md](bedrock-proxy-implementation-plan.md#part-3-dual-auth-middleware)
+- **"What's the config?"** → [QUICK-REFERENCE.md](QUICK-REFERENCE.md#-configuration)
+- **"Show me the code"** → [bedrock-proxy-implementation-plan.md](bedrock-proxy-implementation-plan.md#part-4-openai-v1-api-implementation)
+
+---
+
+## Navigation
+
+All documentation files are in this directory (`docs/`). Files reference each other with relative links:
+- `./bedrock-proxy-implementation-plan.md`
+- `./implementation-checklist.md`
+- `./QUICK-REFERENCE.md`
+
+---
+
+**Last Updated**: June 27, 2026

+ 711 - 0
docs/bedrock-proxy-implementation-plan.md

@@ -0,0 +1,711 @@
+# AWS Bedrock LLM Proxy - Complete Implementation Plan
+**Date**: June 27, 2026 | **Status**: Ready for Development
+
+## 📚 DOCUMENTATION & NAVIGATION
+
+- **📋 Index** → [README.md](README.md)
+- **⚡ Quick Reference** → [QUICK-REFERENCE.md](QUICK-REFERENCE.md)
+- **✅ Checklist** → [implementation-checklist.md](implementation-checklist.md)
+
+## QUICK LINKS
+
+- **"What should I build?"** → [implementation-checklist.md](implementation-checklist.md)
+- **"How does spending work?"** → [Part 5: Spending Enforcement](#part-5-spending-enforcement)
+- **"Database schema?"** → [Part 6: Database Schema](#part-6-database-schema)
+- **"Testing guide?"** → [Part 8: Testing & QA](#part-8-testing--qa)
+
+---
+
+## PART 1: ARCHITECTURE OVERVIEW
+
+### System Flow
+```
+Client Request (OpenAI v1 compatible)
+    ↓
+[Auth Middleware] → Detect Bearer token type (JWT vs opaque)
+    ↓
+[OAuth Validator] OR [Legacy Bearer Validator]
+    ↓
+[Spending Check] → Estimate cost, verify budget
+    ↓
+[Bedrock InvokeModel] → AWS API call, get tokens
+    ↓
+[Atomic Deduction] → PostgreSQL transaction
+    ↓
+[OpenAI-formatted Response] → Back to client
+```
+
+### Key Architecture Decisions
+- **Config**: JSON file mounted as Docker secret at `/run/secrets/bedrock-proxy-config.json`
+- **OAuth Discovery**: Auto-fetch from `https://data.titleproject.space/.well-known/oauth-authorization-server` (cached 24h)
+- **Dual Auth**: Support both Bearer tokens (opaque + JWT) on same `/v1/*` endpoints
+- **API**: OpenAI v1 compatible (drop-in replacement for OpenAI clients)
+- **Models**: Native Bedrock model names (e.g., `claude-3-haiku-20240307`)
+
+---
+
+## PART 2: CONFIGURATION SYSTEM
+
+### Docker Config File Structure
+**File**: `/run/secrets/bedrock-proxy-config.json`
+
+```json
+{
+  "service": {
+    "name": "bedrock-llm-proxy",
+    "port": 3000,
+    "log_level": "info"
+  },
+  "oauth": {
+    "discovery_url": "https://data.titleproject.space/.well-known/oauth-authorization-server",
+    "cache_ttl_hours": 24,
+    "scopes_required": ["llm", "user"]
+  },
+  "bedrock": {
+    "aws_region": "us-east-1",
+    "aws_access_key_id": "AKIA...",
+    "aws_secret_access_key": "..."
+  },
+  "database": {
+    "url": "postgresql://user:pass@db:5432/bedrock_proxy",
+    "pool_size": 20,
+    "statement_timeout_ms": 5000
+  },
+  "spending": {
+    "default_limit_usd": null,
+    "monthly_reset_day": 1,
+    "reset_hour_utc": 0
+  }
+}
+```
+
+### Config Loading at Startup
+```javascript
+// src/lib/config/loader.js
+import fs from 'fs';
+
+export async function loadConfig() {
+  const configPath = process.env.CONFIG_PATH || '/run/secrets/bedrock-proxy-config.json';
+  
+  if (!fs.existsSync(configPath)) {
+    throw new Error(`Config file not found: ${configPath}`);
+  }
+
+  const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+  
+  // Fetch OAuth discovery metadata (cached 24h)
+  config.oauth.metadata = await fetchOAuthMetadata(config.oauth.discovery_url);
+  
+  return config;
+}
+
+async function fetchOAuthMetadata(discoveryUrl) {
+  const response = await fetch(discoveryUrl);
+  const metadata = await response.json();
+  
+  // Returns: { issuer, token_endpoint, introspection_endpoint, jwks_uri, ... }
+  return metadata;
+}
+```
+
+---
+
+## PART 3: DUAL AUTH MIDDLEWARE
+
+### Token Type Detection
+```javascript
+// src/lib/auth/middleware.js
+
+function isJwtToken(token) {
+  // JWTs have exactly 3 base64url parts separated by dots
+  const parts = token.split('.');
+  return parts.length === 3 && parts[0] && parts[1] && parts[2];
+}
+
+export async function authMiddleware(request, config) {
+  const authHeader = request.headers.get('authorization');
+  
+  if (!authHeader) {
+    throw new Error('Missing Authorization header');
+  }
+
+  const [scheme, token] = authHeader.split(' ');
+  if (scheme.toLowerCase() !== 'bearer') {
+    throw new Error('Only Bearer authentication supported');
+  }
+
+  // Route to appropriate validator
+  if (isJwtToken(token)) {
+    return await validateOAuthToken(token, config);
+  } else {
+    return await validateLegacyBearerToken(token, config);
+  }
+}
+```
+
+### OAuth Token Validation (JWT)
+```javascript
+import jwt from 'jsonwebtoken';
+import jwksClient from 'jwks-rsa';
+
+async function validateOAuthToken(token, config) {
+  try {
+    const decoded = jwt.decode(token, { complete: true });
+    if (!decoded) throw new Error('Invalid JWT format');
+    
+    // Fetch signing key from JWKS
+    const client = jwksClient({ jwksUri: config.oauth.metadata.jwks_uri });
+    const key = await client.getSigningKey(decoded.header.kid);
+    
+    // Verify signature
+    const claims = jwt.verify(token, key.getPublicKey(), {
+      algorithms: ['RS256'],
+      issuer: config.oauth.metadata.issuer
+    });
+
+    // Validate scope
+    const tokenScopes = (claims.scope || '').split(' ');
+    const requiredScopes = config.oauth.scopes_required; // ["llm", "user"]
+    
+    if (!requiredScopes.every(s => tokenScopes.includes(s))) {
+      throw new Error(`Insufficient scope. Required: ${requiredScopes.join(', ')}`);
+    }
+
+    return {
+      auth_type: 'oauth',
+      user_id: claims.sub,
+      email: claims.email,
+      scope: claims.scope,
+      expires_at: new Date(claims.exp * 1000)
+    };
+  } catch (error) {
+    throw new Error(`OAuth validation failed: ${error.message}`);
+  }
+}
+```
+
+### Legacy Bearer Token Validation
+```javascript
+async function validateLegacyBearerToken(token, config) {
+  try {
+    // Use OAuth introspection endpoint to validate opaque token
+    const introspectionUrl = config.oauth.metadata.introspection_endpoint;
+    
+    const response = await fetch(introspectionUrl, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+      body: `token=${encodeURIComponent(token)}`
+    });
+
+    if (!response.ok) throw new Error('Token introspection failed');
+    
+    const data = await response.json();
+    
+    if (!data.active) {
+      throw new Error('Token inactive or expired');
+    }
+
+    return {
+      auth_type: 'bearer',
+      user_id: data.sub,
+      email: data.username,
+      scope: data.scope,
+      expires_at: new Date(data.exp * 1000)
+    };
+  } catch (error) {
+    throw new Error(`Bearer token validation failed: ${error.message}`);
+  }
+}
+```
+
+### SvelteKit Hook Integration
+```javascript
+// src/hooks.server.js
+import { authMiddleware } from '$lib/auth/middleware.js';
+import { loadConfig } from '$lib/config/loader.js';
+
+let config;
+
+export async function init() {
+  config = await loadConfig();
+}
+
+export async function handle({ event, resolve }) {
+  // Skip auth for public endpoints
+  if (!event.url.pathname.startsWith('/api/v1/')) {
+    return resolve(event);
+  }
+
+  try {
+    event.locals.user = await authMiddleware(event.request, config);
+  } catch (error) {
+    return new Response(
+      JSON.stringify({
+        error: {
+          code: 'authentication_error',
+          message: error.message,
+          type: 'authentication_error'
+        }
+      }),
+      { status: 401, headers: { 'Content-Type': 'application/json' } }
+    );
+  }
+
+  return resolve(event);
+}
+```
+
+---
+
+## PART 4: OPENAI v1 API IMPLEMENTATION
+
+### Endpoint: POST /v1/chat/completions
+
+**Request**:
+```json
+{
+  "model": "claude-3-haiku-20240307",
+  "messages": [
+    {"role": "system", "content": "You are helpful."},
+    {"role": "user", "content": "Hello"}
+  ],
+  "temperature": 0.7,
+  "max_tokens": 1024,
+  "stream": false
+}
+```
+
+**Response**:
+```json
+{
+  "id": "chatcmpl-123abc",
+  "object": "chat.completion",
+  "created": 1704067200,
+  "model": "claude-3-haiku-20240307",
+  "choices": [
+    {
+      "index": 0,
+      "message": {
+        "role": "assistant",
+        "content": "Hello! How can I help?"
+      },
+      "finish_reason": "stop"
+    }
+  ],
+  "usage": {
+    "prompt_tokens": 10,
+    "completion_tokens": 8,
+    "total_tokens": 18
+  }
+}
+```
+
+**Implementation**: `src/routes/api/v1/chat/completions/+server.js`
+
+```javascript
+import { json } from '@sveltejs/kit';
+import { invokeBedrockModel } from '$lib/bedrock/client.js';
+import { checkSpendingLimit, deductSpending } from '$lib/spending/enforcer.js';
+import { estimateCost } from '$lib/bedrock/models.js';
+
+export async function POST({ request, locals }) {
+  const payload = await request.json();
+  const { model, messages, temperature, max_tokens, stream } = payload;
+
+  // Validation
+  if (!model || !messages) {
+    return json({
+      error: {
+        code: 'invalid_request_error',
+        message: 'model and messages are required'
+      }
+    }, { status: 400 });
+  }
+
+  const userId = locals.user.user_id;
+
+  // Pre-flight spending check
+  const costEstimate = estimateCost(model, messages, max_tokens);
+  const spendingCheck = await checkSpendingLimit(userId, model, messages);
+  
+  if (!spendingCheck.allowed) {
+    return json({
+      error: {
+        code: 'insufficient_credits',
+        message: `Budget exceeded. Remaining: $${spendingCheck.remaining}`
+      }
+    }, { status: 429 });
+  }
+
+  try {
+    // Call Bedrock
+    const response = await invokeBedrockModel(model, messages, {
+      temperature: temperature || 0.7,
+      max_tokens: max_tokens || 1024
+    });
+
+    // Deduct actual cost
+    await deductSpending(userId, model, response.input_tokens, response.output_tokens);
+
+    // Format OpenAI response
+    return json({
+      id: `bedrock-${Date.now()}`,
+      object: 'chat.completion',
+      created: Math.floor(Date.now() / 1000),
+      model,
+      choices: [{
+        index: 0,
+        message: {
+          role: 'assistant',
+          content: response.completion
+        },
+        finish_reason: 'stop'
+      }],
+      usage: {
+        prompt_tokens: response.input_tokens,
+        completion_tokens: response.output_tokens,
+        total_tokens: response.input_tokens + response.output_tokens
+      }
+    });
+  } catch (error) {
+    if (error.message.includes('Model not found')) {
+      return json({
+        error: {
+          code: 'model_not_found_error',
+          message: `Model not supported: ${model}`
+        }
+      }, { status: 404 });
+    }
+    
+    return json({
+      error: {
+        code: 'server_error',
+        message: error.message,
+        type: 'server_error'
+      }
+    }, { status: 500 });
+  }
+}
+```
+
+### Endpoint: GET /v1/models
+
+**Response**:
+```json
+{
+  "object": "list",
+  "data": [
+    {
+      "id": "claude-3-haiku-20240307",
+      "object": "model",
+      "created": 1704067200,
+      "owned_by": "anthropic",
+      "permission": [],
+      "root": "claude-3-haiku-20240307",
+      "parent": null
+    }
+  ]
+}
+```
+
+**Implementation**: `src/routes/api/v1/models/+server.js`
+
+```javascript
+import { json } from '@sveltejs/kit';
+import postgres from 'postgres';
+
+const sql = postgres(process.env.DATABASE_URL);
+
+export async function GET({ locals }) {
+  const models = await sql`
+    SELECT model_id, provider, created_at FROM bedrock_models WHERE supported = true
+  `;
+
+  return json({
+    object: 'list',
+    data: models.map(m => ({
+      id: m.model_id,
+      object: 'model',
+      created: Math.floor(new Date(m.created_at).getTime() / 1000),
+      owned_by: m.provider,
+      permission: [],
+      root: m.model_id,
+      parent: null
+    }))
+  });
+}
+```
+
+### Streaming Endpoint: POST /v1/chat/completions (stream: true)
+
+**Response** (Server-Sent Events):
+```
+event: delta
+data: {"choices":[{"index":0,"delta":{"content":"Hello"}}]}
+
+event: delta
+data: {"choices":[{"index":0,"delta":{"content":" there"}}]}
+
+event: delta
+data: {"choices":[{"index":0,"finish_reason":"stop"}]}
+```
+
+**Implementation**:
+
+```javascript
+// Add to POST handler above:
+
+if (stream) {
+  return new Response(streamBedrockResponse(model, messages, userId), {
+    headers: {
+      'Content-Type': 'text/event-stream',
+      'Cache-Control': 'no-cache',
+      'Connection': 'keep-alive'
+    }
+  });
+}
+
+async function* streamBedrockResponse(model, messages, userId) {
+  try {
+    const response = await invokeBedrockModelStreaming(model, messages);
+    
+    for await (const delta of response.deltas) {
+      yield `event: delta\ndata: ${JSON.stringify({
+        choices: [{ index: 0, delta: { content: delta.token } }]
+      })}\n\n`;
+    }
+
+    // Final message with finish_reason
+    yield `event: delta\ndata: ${JSON.stringify({
+      choices: [{ index: 0, finish_reason: 'stop' }]
+    })}\n\n`;
+
+    // Deduct actual cost after streaming completes
+    await deductSpending(userId, model, response.input_tokens, response.output_tokens);
+  } catch (error) {
+    yield `event: error\ndata: ${JSON.stringify({
+      error: {
+        code: 'server_error',
+        message: error.message
+      }
+    })}\n\n`;
+  }
+}
+```
+
+---
+
+## PART 5: SPENDING ENFORCEMENT
+
+### Pre-Flight Check
+```javascript
+// src/lib/spending/enforcer.js
+import postgres from 'postgres';
+
+export async function checkSpendingLimit(userId, modelId, messages) {
+  const sql = postgres(process.env.DATABASE_URL);
+  
+  // Get user's current balance
+  const [user] = await sql`
+    SELECT user_id, current_spend_usd, monthly_limit_usd, spend_reset_at
+    FROM users
+    WHERE user_id = ${userId}
+  `;
+
+  if (!user) {
+    throw new Error('User not found');
+  }
+
+  // Check if spending period has reset
+  if (new Date() > user.spend_reset_at) {
+    await sql`
+      UPDATE users
+      SET current_spend_usd = 0, spend_reset_at = NOW() + INTERVAL '1 month'
+      WHERE user_id = ${userId}
+    `;
+    user.current_spend_usd = 0;
+  }
+
+  // If no limit set, allow unlimited
+  if (user.monthly_limit_usd === null) {
+    return { allowed: true, reason: 'unlimited' };
+  }
+
+  // Estimate tokens for this request
+  const messageText = messages.map(m => m.content).join(' ');
+  const estimatedTokens = Math.ceil(messageText.length / 4) * 1.2; // 20% buffer
+  const estimatedCost = (estimatedTokens * 0.001); // Rough estimate
+
+  const remainingBudget = user.monthly_limit_usd - user.current_spend_usd;
+  
+  if (estimatedCost > remainingBudget) {
+    return {
+      allowed: false,
+      remaining: remainingBudget,
+      estimated: estimatedCost
+    };
+  }
+
+  return { allowed: true, estimated_cost: estimatedCost };
+}
+
+export async function deductSpending(userId, modelId, inputTokens, outputTokens) {
+  const sql = postgres(process.env.DATABASE_URL);
+  
+  // Get model pricing
+  const [model] = await sql`
+    SELECT input_price_usd, output_price_usd FROM bedrock_models WHERE model_id = ${modelId}
+  `;
+
+  if (!model) throw new Error(`Model ${modelId} not found`);
+
+  const actualCost = (
+    (inputTokens * model.input_price_usd + outputTokens * model.output_price_usd) / 1_000_000
+  );
+
+  // Atomic deduction
+  await sql`
+    BEGIN;
+    
+    -- Lock row to prevent race conditions
+    SELECT user_id FROM users WHERE user_id = ${userId} FOR UPDATE;
+    
+    -- Deduct cost
+    UPDATE users
+    SET current_spend_usd = current_spend_usd + ${actualCost}
+    WHERE user_id = ${userId};
+
+    -- Log transaction
+    INSERT INTO spending_records (user_id, model_id, input_tokens, output_tokens, cost_usd)
+    VALUES (${userId}, ${modelId}, ${inputTokens}, ${outputTokens}, ${actualCost});
+    
+    COMMIT;
+  `;
+
+  return { cost_deducted: actualCost };
+}
+```
+
+---
+
+## PART 6: DATABASE SCHEMA
+
+```sql
+-- Users table
+CREATE TABLE users (
+  user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  oauth_sub VARCHAR(255) UNIQUE NOT NULL,
+  email VARCHAR(255) UNIQUE NOT NULL,
+  monthly_limit_usd DECIMAL(10, 2) DEFAULT NULL,
+  current_spend_usd DECIMAL(10, 2) DEFAULT 0,
+  spend_reset_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + INTERVAL '1 month',
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Spending records
+CREATE TABLE spending_records (
+  record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  user_id UUID NOT NULL REFERENCES users(user_id),
+  timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+  model_id VARCHAR(64) NOT NULL,
+  input_tokens INT NOT NULL,
+  output_tokens INT NOT NULL,
+  cost_usd DECIMAL(10, 4) NOT NULL
+);
+
+-- Bedrock models
+CREATE TABLE bedrock_models (
+  model_id VARCHAR(64) PRIMARY KEY,
+  provider VARCHAR(32) NOT NULL,
+  input_price_usd DECIMAL(10, 6) NOT NULL,
+  output_price_usd DECIMAL(10, 6) NOT NULL,
+  supported BOOLEAN DEFAULT TRUE,
+  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Indexes
+CREATE INDEX idx_users_oauth_sub ON users(oauth_sub);
+CREATE INDEX idx_spending_user_timestamp ON spending_records(user_id, timestamp DESC);
+
+-- Monthly reset (cron: runs daily at 00:00 UTC)
+CREATE OR REPLACE FUNCTION reset_monthly_spend()
+RETURNS void AS $$
+BEGIN
+  UPDATE users
+  SET current_spend_usd = 0, spend_reset_at = NOW() + INTERVAL '1 month'
+  WHERE spend_reset_at <= NOW();
+END;
+$$ LANGUAGE plpgsql;
+```
+
+---
+
+## PART 7: IMPLEMENTATION PHASES
+
+### Phase 1: MVP (Days 1-3)
+- [x] Config loading from Docker secret
+- [x] OAuth discovery endpoint integration
+- [x] Dual auth middleware (JWT + Bearer)
+- [x] Single model (Claude Haiku)
+- [x] Basic spending tracking
+- [x] OpenAI `/v1/chat/completions` endpoint (non-streaming)
+
+**Deliverable**:
+```bash
+curl -X POST http://localhost:3000/api/v1/chat/completions \
+  -H "Authorization: Bearer $JWT_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "model": "claude-3-haiku-20240307",
+    "messages": [{"role": "user", "content": "Hello"}]
+  }' | jq .
+```
+
+### Phase 2: Multi-Model + Dashboard (Days 4-6)
+- [ ] All Bedrock models support
+- [ ] `/v1/models` endpoint
+- [ ] Streaming support (`stream: true`)
+- [ ] User spend dashboard
+- [ ] Admin analytics
+
+### Phase 3: Admin Controls (Days 7-10, Optional)
+- [ ] Admin UI to adjust user limits
+- [ ] Email notifications at 50%/80%/100%
+- [ ] Spending reports (CSV export)
+
+---
+
+## PART 8: TESTING & QA
+
+### Phase 1 QA (Curl-based)
+```bash
+# Test 1: Valid OAuth token
+TOKEN=$(curl -s https://title-graphql.example.com/oauth/token | jq -r .access_token)
+curl -X POST http://localhost:3000/api/v1/chat/completions \
+  -H "Authorization: Bearer $TOKEN" \
+  -d '{"model":"claude-3-haiku-20240307","messages":[{"role":"user","content":"Hi"}]}'
+
+# Test 2: Invalid token
+curl -X POST http://localhost:3000/api/v1/chat/completions \
+  -H "Authorization: Bearer invalid_token" \
+  -d '{"model":"claude-3-haiku-20240307","messages":[{"role":"user","content":"Hi"}]}'
+# Expected: 401 authentication_error
+
+# Test 3: Spending limit
+curl -X POST http://localhost:3000/api/v1/chat/completions \
+  -H "Authorization: Bearer $TOKEN" \
+  -d '{"model":"claude-3-haiku-20240307","messages":[{"role":"user","content":"'$(printf 'a%.0s' {1..10000})'"}]}'
+# Expected: 429 insufficient_credits (if limit exceeded)
+```
+
+---
+
+## NEXT STEPS
+
+1. **Verify OAuth discovery works**: `curl https://data.titleproject.space/.well-known/oauth-authorization-server`
+2. **Setup PostgreSQL**: Run schema DDL
+3. **Create AWS IAM user**: With `bedrock:InvokeModel` permission
+4. **Mount config file**: `/run/secrets/bedrock-proxy-config.json`
+5. **Start Phase 1 implementation**: Begin with auth middleware

+ 132 - 0
docs/implementation-checklist.md

@@ -0,0 +1,132 @@
+# AWS Bedrock LLM Proxy - Implementation Checklist
+
+## 📚 DOCUMENTATION & NAVIGATION
+
+- **📋 Index** → [README.md](README.md)
+- **📝 Quick Reference** → [QUICK-REFERENCE.md](QUICK-REFERENCE.md)
+- **📖 Full Plan** → [bedrock-proxy-implementation-plan.md](bedrock-proxy-implementation-plan.md)
+
+## QUICK LINKS
+
+- **"What's the architecture?"** → [QUICK-REFERENCE.md](QUICK-REFERENCE.md)
+- **"Show me the code"** → [bedrock-proxy-implementation-plan.md](bedrock-proxy-implementation-plan.md)
+
+---
+
+## PHASE 1: MVP (Days 1-3)
+
+### Day 1: Setup & Config
+- [ ] Create `/run/secrets/bedrock-proxy-config.json` template
+- [ ] Implement `src/lib/config/loader.js` (load config + fetch OAuth discovery)
+- [ ] Setup PostgreSQL schema (users, spending_records, bedrock_models tables)
+- [ ] Create `.env` for DATABASE_URL only
+
+### Day 1-2: Auth Middleware
+- [ ] Implement token type detection (JWT vs opaque Bearer)
+- [ ] Implement `src/lib/auth/middleware.js`:
+  - JWT validation: JWKS from OAuth discovery
+  - Bearer validation: Token introspection endpoint
+- [ ] Integrate into `src/hooks.server.js`
+- [ ] Test with curl: Bearer token → 200 OK, Invalid token → 401
+
+### Day 2-3: Bedrock Integration
+- [ ] AWS SDK setup (`@aws-sdk/client-bedrock-runtime`)
+- [ ] `src/lib/bedrock/client.js`: InvokeModel wrapper
+- [ ] `src/lib/bedrock/models.js`: Model registry + pricing
+- [ ] Implement POST `/api/v1/chat/completions`:
+  - Pre-flight spending check
+  - Bedrock call
+  - Atomic cost deduction
+- [ ] OpenAI response formatting
+
+### Day 3: Testing
+- [ ] Manual curl tests:
+  - Valid OAuth token → LLM response
+  - Invalid token → 401
+  - Spending limit → 429
+- [ ] Verify Bedrock token counts extracted correctly
+- [ ] Verify cost calculation matches pricing
+
+---
+
+## PHASE 2: Multi-Model + Dashboard (Days 4-6)
+
+### Day 4: Model Registry
+- [ ] Add all Bedrock models to database
+- [ ] Implement GET `/api/v1/models`
+- [ ] Per-model pricing configuration
+
+### Day 5: Streaming
+- [ ] Implement streaming endpoint (SSE)
+- [ ] Handle mid-request token counting
+- [ ] Cost deduction after streaming completes
+
+### Day 6: Dashboard
+- [ ] User dashboard: `/dashboard`
+  - Current spend / monthly limit
+  - Spend history (last 30 days)
+- [ ] Admin analytics: `/admin/analytics`
+  - All users' spend totals
+  - Cost trends
+
+---
+
+## PHASE 3: Admin Controls [OPTIONAL]
+
+- [ ] Admin UI to adjust per-user limits
+- [ ] Email notifications at spending thresholds
+- [ ] Spending reports (CSV export)
+
+---
+
+## KEY TECHNICAL TASKS
+
+### Authentication
+- [x] Token type detection (JWT vs opaque)
+- [x] OAuth discovery integration
+- [x] JWKS validation
+- [x] Token introspection
+- [x] Error handling (OpenAI format)
+
+### Spending Enforcement
+- [x] Pre-flight cost estimation
+- [x] Atomic deduction with row-level locks
+- [x] Monthly reset trigger
+- [x] Rate limiting per auth type
+
+### API Compatibility
+- [x] POST /v1/chat/completions (non-streaming)
+- [ ] POST /v1/chat/completions (streaming)
+- [x] GET /v1/models
+- [x] Error responses (OpenAI format)
+- [x] Request/response field mapping
+
+### Database
+- [x] Schema design (users, spending_records, bedrock_models)
+- [ ] Indexing for performance
+- [ ] Monthly reset trigger/cron
+
+---
+
+## QA MATRIX
+
+| Test | Command | Expected | Status |
+|------|---------|----------|--------|
+| OAuth JWT | curl + JWT token | 200 OK | [ ] |
+| Invalid JWT | curl + expired JWT | 401 auth_error | [ ] |
+| Opaque Bearer | curl + title-graphql token | 200 OK | [ ] |
+| Spending Limit | 11 requests with $10 limit | 429 on 11th | [ ] |
+| Cost Deduction | Check database | $X.XX deducted | [ ] |
+| Model List | curl /v1/models | JSON array | [ ] |
+| Streaming | stream: true | SSE format | [ ] |
+
+---
+
+## CONFIG REQUIREMENTS
+
+Before starting:
+1. [ ] AWS IAM user with bedrock:InvokeModel permission
+2. [ ] PostgreSQL database accessible
+3. [ ] OAuth discovery URL verified: https://data.titleproject.space/.well-known/oauth-authorization-server
+4. [ ] title-graphql introspection endpoint working
+

+ 5 - 0
messages/en-us.json

@@ -0,0 +1,5 @@
+{
+	"$schema": "https://inlang.com/schema/inlang-message-format",
+	"welcome": "Welcome to SvelteKit!",
+	"welcome_documentation": "Visit the following address for documentation"
+}

+ 5 - 0
messages/es-mx.json

@@ -0,0 +1,5 @@
+{
+	"$schema": "https://inlang.com/schema/inlang-message-format",
+	"welcome": "¡Bienvenido a SvelteKit!",
+	"welcome_documentation": "Visite la siguiente dirección para leer la documentación"
+}

+ 1 - 0
package.json

@@ -10,6 +10,7 @@
 		"prepare": "svelte-kit sync || echo ''"
 	},
 	"devDependencies": {
+		"@inlang/paraglide-js": "^2.18.2",
 		"@sveltejs/adapter-node": "^5.5.7",
 		"@sveltejs/kit": "^2.63.0",
 		"@sveltejs/vite-plugin-svelte": "^7.1.2",

+ 181 - 0
pnpm-lock.yaml

@@ -8,6 +8,9 @@ importers:
 
   .:
     devDependencies:
+      '@inlang/paraglide-js':
+        specifier: ^2.18.2
+        version: 2.20.2
       '@sveltejs/adapter-node':
         specifier: ^5.5.7
         version: 5.5.7(@sveltejs/kit@2.68.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4)(vite@8.1.0(jiti@2.7.0)))(svelte@5.56.4)(vite@8.1.0(jiti@2.7.0)))
@@ -69,6 +72,22 @@ packages:
   '@floating-ui/utils@0.2.11':
     resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
 
+  '@inlang/paraglide-js@2.20.2':
+    resolution: {integrity: sha512-V8iY3uu/vQU94gEag1bdC3glMJSp4Dg3XMwfnabZLBh1Dv0F++DvDYlMeniqv2+nHbnS/twB75AM140OmpHDEg==}
+    hasBin: true
+    peerDependencies:
+      typescript: '>=5.6'
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
+  '@inlang/recommend-sherlock@0.2.1':
+    resolution: {integrity: sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==}
+
+  '@inlang/sdk@2.10.2':
+    resolution: {integrity: sha512-O1ki72SNK6LPagaGrvlioBb1mWKvump7cO7P85hfGZjdFTmDdn3icI0A6MvaBsB3P9KQHAjzyubnN1OslGufTw==}
+    engines: {node: '>=20.0.0'}
+
   '@jridgewell/gen-mapping@0.3.13':
     resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
 
@@ -85,6 +104,13 @@ packages:
   '@jridgewell/trace-mapping@0.3.31':
     resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
 
+  '@lix-js/sdk@0.4.10':
+    resolution: {integrity: sha512-0dMInAJK/67guTG5rRZaCEhvzC5cCXENOjaePA5AqMXrCE97kaY7SRor9e2vnoGsFIiGqXKlT0MCIoZj36G0gg==}
+    engines: {node: '>=18'}
+
+  '@lix-js/server-protocol-schema@0.1.1':
+    resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==}
+
   '@napi-rs/wasm-runtime@1.1.6':
     resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
     peerDependencies:
@@ -390,6 +416,13 @@ packages:
     cpu: [x64]
     os: [win32]
 
+  '@sinclair/typebox@0.31.28':
+    resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==}
+
+  '@sqlite.org/sqlite-wasm@3.48.0-build4':
+    resolution: {integrity: sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==}
+    hasBin: true
+
   '@standard-schema/spec@1.1.0':
     resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
 
@@ -560,6 +593,9 @@ packages:
     resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
     engines: {node: '>= 0.4'}
 
+  array-timsort@1.0.3:
+    resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==}
+
   axobject-query@4.1.0:
     resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
     engines: {node: '>= 0.4'}
@@ -568,9 +604,21 @@ packages:
     resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
     engines: {node: '>=6'}
 
+  commander@11.1.0:
+    resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
+    engines: {node: '>=16'}
+
+  comment-json@4.6.2:
+    resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==}
+    engines: {node: '>= 6'}
+
   commondir@1.0.1:
     resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
 
+  consola@3.4.0:
+    resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==}
+    engines: {node: ^14.18.0 || >=16.10.0}
+
   cookie@0.6.0:
     resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
     engines: {node: '>= 0.6'}
@@ -583,6 +631,14 @@ packages:
   date-fns@4.4.0:
     resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
 
+  dedent@1.5.1:
+    resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==}
+    peerDependencies:
+      babel-plugin-macros: ^3.1.0
+    peerDependenciesMeta:
+      babel-plugin-macros:
+        optional: true
+
   deepmerge@4.3.1:
     resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
     engines: {node: '>=0.10.0'}
@@ -605,6 +661,11 @@ packages:
   esm-env@1.2.2:
     resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
 
+  esprima@4.0.1:
+    resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
+    engines: {node: '>=4'}
+    hasBin: true
+
   esrap@2.2.12:
     resolution: {integrity: sha512-On0QbLyaiAkVC4eXtgnXK9Kh2opit+3rcUSOc45DqJ2s/X2eXAHsGOKRSJ6IDagQEW5vPyivANfXUiqgXC67Rw==}
     peerDependencies:
@@ -666,6 +727,10 @@ packages:
     resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
     engines: {node: '>= 0.4'}
 
+  human-id@4.2.0:
+    resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==}
+    hasBin: true
+
   is-core-module@2.16.2:
     resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
     engines: {node: '>= 0.4'}
@@ -683,10 +748,22 @@ packages:
     resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
     hasBin: true
 
+  js-sha256@0.11.1:
+    resolution: {integrity: sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==}
+
+  json5@2.2.3:
+    resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+    engines: {node: '>=6'}
+    hasBin: true
+
   kleur@4.1.5:
     resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
     engines: {node: '>=6'}
 
+  kysely@0.28.17:
+    resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==}
+    engines: {node: '>=20.0.0'}
+
   lightningcss-android-arm64@1.32.0:
     resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
     engines: {node: '>= 12.0.0'}
@@ -828,6 +905,11 @@ packages:
     resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
     engines: {node: '>=0.10.0'}
 
+  sqlite-wasm-kysely@0.3.0:
+    resolution: {integrity: sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==}
+    peerDependencies:
+      kysely: '*'
+
   supports-preserve-symlinks-flag@1.0.0:
     resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
     engines: {node: '>= 0.4'}
@@ -867,9 +949,20 @@ packages:
   tslib@2.8.1:
     resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
 
+  unplugin@2.3.11:
+    resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
+    engines: {node: '>=18.12.0'}
+
+  urlpattern-polyfill@10.1.0:
+    resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==}
+
   util-deprecate@1.0.2:
     resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
 
+  uuid@14.0.1:
+    resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
+    hasBin: true
+
   vite@8.1.0:
     resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==}
     engines: {node: ^20.19.0 || >=22.12.0}
@@ -921,6 +1014,9 @@ packages:
       vite:
         optional: true
 
+  webpack-virtual-modules@0.6.2:
+    resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
+
   zimmerframe@1.1.4:
     resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
 
@@ -955,6 +1051,32 @@ snapshots:
 
   '@floating-ui/utils@0.2.11': {}
 
+  '@inlang/paraglide-js@2.20.2':
+    dependencies:
+      '@inlang/recommend-sherlock': 0.2.1
+      '@inlang/sdk': 2.10.2
+      commander: 11.1.0
+      consola: 3.4.0
+      json5: 2.2.3
+      unplugin: 2.3.11
+      urlpattern-polyfill: 10.1.0
+    transitivePeerDependencies:
+      - babel-plugin-macros
+
+  '@inlang/recommend-sherlock@0.2.1':
+    dependencies:
+      comment-json: 4.6.2
+
+  '@inlang/sdk@2.10.2':
+    dependencies:
+      '@lix-js/sdk': 0.4.10
+      '@sinclair/typebox': 0.31.28
+      kysely: 0.28.17
+      sqlite-wasm-kysely: 0.3.0(kysely@0.28.17)
+      uuid: 14.0.1
+    transitivePeerDependencies:
+      - babel-plugin-macros
+
   '@jridgewell/gen-mapping@0.3.13':
     dependencies:
       '@jridgewell/sourcemap-codec': 1.5.5
@@ -974,6 +1096,20 @@ snapshots:
       '@jridgewell/resolve-uri': 3.1.2
       '@jridgewell/sourcemap-codec': 1.5.5
 
+  '@lix-js/sdk@0.4.10':
+    dependencies:
+      '@lix-js/server-protocol-schema': 0.1.1
+      dedent: 1.5.1
+      human-id: 4.2.0
+      js-sha256: 0.11.1
+      kysely: 0.28.17
+      sqlite-wasm-kysely: 0.3.0(kysely@0.28.17)
+      uuid: 14.0.1
+    transitivePeerDependencies:
+      - babel-plugin-macros
+
+  '@lix-js/server-protocol-schema@0.1.1': {}
+
   '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
     dependencies:
       '@emnapi/core': 1.11.1
@@ -1166,6 +1302,10 @@ snapshots:
   '@rollup/rollup-win32-x64-msvc@4.62.2':
     optional: true
 
+  '@sinclair/typebox@0.31.28': {}
+
+  '@sqlite.org/sqlite-wasm@3.48.0-build4': {}
+
   '@standard-schema/spec@1.1.0': {}
 
   '@sveltejs/acorn-typescript@1.0.10(acorn@8.17.0)':
@@ -1313,18 +1453,31 @@ snapshots:
 
   aria-query@5.3.1: {}
 
+  array-timsort@1.0.3: {}
+
   axobject-query@4.1.0: {}
 
   clsx@2.1.1: {}
 
+  commander@11.1.0: {}
+
+  comment-json@4.6.2:
+    dependencies:
+      array-timsort: 1.0.3
+      esprima: 4.0.1
+
   commondir@1.0.1: {}
 
+  consola@3.4.0: {}
+
   cookie@0.6.0: {}
 
   cssesc@3.0.0: {}
 
   date-fns@4.4.0: {}
 
+  dedent@1.5.1: {}
+
   deepmerge@4.3.1: {}
 
   detect-libc@2.1.2: {}
@@ -1340,6 +1493,8 @@ snapshots:
 
   esm-env@1.2.2: {}
 
+  esprima@4.0.1: {}
+
   esrap@2.2.12:
     dependencies:
       '@jridgewell/sourcemap-codec': 1.5.5
@@ -1424,6 +1579,8 @@ snapshots:
     dependencies:
       function-bind: 1.1.2
 
+  human-id@4.2.0: {}
+
   is-core-module@2.16.2:
     dependencies:
       hasown: 2.0.4
@@ -1440,8 +1597,14 @@ snapshots:
 
   jiti@2.7.0: {}
 
+  js-sha256@0.11.1: {}
+
+  json5@2.2.3: {}
+
   kleur@4.1.5: {}
 
+  kysely@0.28.17: {}
+
   lightningcss-android-arm64@1.32.0:
     optional: true
 
@@ -1591,6 +1754,11 @@ snapshots:
 
   source-map-js@1.2.1: {}
 
+  sqlite-wasm-kysely@0.3.0(kysely@0.28.17):
+    dependencies:
+      '@sqlite.org/sqlite-wasm': 3.48.0-build4
+      kysely: 0.28.17
+
   supports-preserve-symlinks-flag@1.0.0: {}
 
   svelte@5.56.4:
@@ -1636,8 +1804,19 @@ snapshots:
   tslib@2.8.1:
     optional: true
 
+  unplugin@2.3.11:
+    dependencies:
+      '@jridgewell/remapping': 2.3.5
+      acorn: 8.17.0
+      picomatch: 4.0.4
+      webpack-virtual-modules: 0.6.2
+
+  urlpattern-polyfill@10.1.0: {}
+
   util-deprecate@1.0.2: {}
 
+  uuid@14.0.1: {}
+
   vite@8.1.0(jiti@2.7.0):
     dependencies:
       lightningcss: 1.32.0
@@ -1653,4 +1832,6 @@ snapshots:
     optionalDependencies:
       vite: 8.1.0(jiti@2.7.0)
 
+  webpack-virtual-modules@0.6.2: {}
+
   zimmerframe@1.1.4: {}

+ 15 - 0
project.inlang/settings.json

@@ -0,0 +1,15 @@
+{
+	"$schema": "https://inlang.com/schema/project-settings",
+	"modules": [
+		"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
+		"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
+	],
+	"plugin.inlang.messageFormat": {
+		"pathPattern": "./messages/{locale}.json"
+	},
+	"baseLocale": "en-us",
+	"locales": [
+		"en-us",
+		"es-mx"
+	]
+}

+ 17 - 5
src/app.html

@@ -1,12 +1,24 @@
 <!doctype html>
-<html class="dark" lang="en">
+
+<html
+	class="dark"
+	lang="%paraglide.lang%"
+	dir="%paraglide.dir%"
+>
 	<head>
 		<meta charset="utf-8" />
-		<meta name="viewport" content="width=device-width, initial-scale=1" />
+
+		<meta
+			name="viewport"
+			content="width=device-width, initial-scale=1"
+		/>
+
 		<meta name="text-scale" content="scale" />
 		%sveltekit.head%
 	</head>
-	<body lass="bg-white dark:bg-gray-700" data-sveltekit-preload-data="hover">
-		<div style="display: contents">%sveltekit.body%</div>
-	</body>
+
+	<body
+		lass="bg-white dark:bg-gray-700"
+		data-sveltekit-preload-data="hover"
+	><div style="display: contents">%sveltekit.body%</div></body>
 </html>

+ 3 - 0
src/hooks.js

@@ -0,0 +1,3 @@
+import { deLocalizeUrl } from '$lib/paraglide/runtime';
+
+/** @type {import('@sveltejs/kit').Reroute} */ export const reroute = (request) => deLocalizeUrl(request.url).pathname;

+ 12 - 0
src/hooks.server.js

@@ -0,0 +1,12 @@
+import { getTextDirection } from '$lib/paraglide/runtime';
+import { paraglideMiddleware } from '$lib/paraglide/server';
+
+/** @type {import('@sveltejs/kit').Handle} */ const handleParaglide = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
+	event.request = request;
+
+	return resolve(event, {
+		transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale).replace('%paraglide.dir%', getTextDirection(locale))
+	});
+});
+
+export /** @type {import('@sveltejs/kit').Handle} */ const handle = handleParaglide;

+ 11 - 0
src/routes/+layout.svelte

@@ -1,4 +1,7 @@
 <script>
+	import { resolve } from '$app/paths';
+	import { page } from '$app/state';
+	import { locales, localizeHref } from '$lib/paraglide/runtime';
 	import './layout.css';
 	import favicon from '$lib/assets/favicon.svg';
 
@@ -7,3 +10,11 @@
 
 <svelte:head><link rel="icon" href={favicon} /></svelte:head>
 {@render children()}
+
+<div style="display:none">
+	{#each locales as locale (locale)}
+		<a
+			href={resolve(localizeHref(page.url.pathname, { locale }))}
+		>{locale}</a>
+	{/each}
+</div>

+ 29 - 5
src/routes/+page.svelte

@@ -1,13 +1,37 @@
 <script>
-  import { Alert } from "flowbite-svelte";
+  import {
+    Alert,
+    Button,
+    ButtonGroup,
+  } from 'flowbite-svelte';
 
-  import { InfoCircleSolid } from "flowbite-svelte-icons";
+  import {
+    getLocale,
+    setLocale,
+  } from '$lib/paraglide/runtime';
+
+  import { m } from '$lib/paraglide/messages.js';
+
+  import {
+    CheckCircleSolid,
+    GlobeSolid,
+    InfoCircleSolid,
+  } from "flowbite-svelte-icons";
 </script>
 
 <div class="p-8">
-  <Alert color="green">
+  <Alert color="green" class="mb-4">
     {#snippet icon()}<InfoCircleSolid class="h-5 w-5" />{/snippet}
-    <span class="font-medium">Welcome to SvelteKit!</span>
-		Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation
+    <span class="font-medium">{m.welcome()}</span>
+    {m.welcome_documentation()}: <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a>
   </Alert>
+
+  <ButtonGroup>
+    {#each [ { tag: 'en-us', label: 'English' }, { tag: 'es-mx', label: 'Español' } ] as language}
+      <Button color={getLocale() === language.tag ? 'green' : 'dark'} disabled={getLocale() === language.tag} onclick={() => setLocale(language.tag)}>
+        {#if getLocale() === language.tag}<CheckCircleSolid class="mr-2" />{:else}<GlobeSolid class="mr-2" />{/if}
+        {language.label}
+      </Button>
+    {/each}
+  </ButtonGroup>
 </div>

+ 5 - 4
vite.config.js

@@ -1,7 +1,7 @@
+import { paraglideVitePlugin } from '@inlang/paraglide-js';
 import tailwindcss from '@tailwindcss/vite';
-import adapter     from '@sveltejs/adapter-node';
-
-import { sveltekit    } from '@sveltejs/kit/vite';
+import adapter from '@sveltejs/adapter-node';
+import { sveltekit } from '@sveltejs/kit/vite';
 import { defineConfig } from 'vite';
 
 export default defineConfig({
@@ -13,7 +13,8 @@ export default defineConfig({
 				runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true
 			},
 			adapter: adapter()
-		})
+		}),
+		paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' })
 	],
 	server: { port: 3000 }
 });