Changeset 0.28.8 (#194)
This commit is contained in:
@@ -415,8 +415,8 @@ jobs:
|
||||
echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "FE_CPU_REQUEST=25m" >> "$GITHUB_OUTPUT"
|
||||
echo "FE_CPU_LIMIT=100m" >> "$GITHUB_OUTPUT"
|
||||
echo "BE_MEMORY_REQUEST=128Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "BE_MEMORY_LIMIT=256Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "BE_MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "BE_MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "BE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
||||
echo "BE_CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
||||
echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT"
|
||||
@@ -456,8 +456,8 @@ jobs:
|
||||
echo "FE_MEMORY_LIMIT=128Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "FE_CPU_REQUEST=25m" >> "$GITHUB_OUTPUT"
|
||||
echo "FE_CPU_LIMIT=100m" >> "$GITHUB_OUTPUT"
|
||||
echo "BE_MEMORY_REQUEST=128Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "BE_MEMORY_LIMIT=256Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "BE_MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "BE_MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||
echo "BE_CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
||||
echo "BE_CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
||||
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
|
||||
@@ -709,6 +709,7 @@ jobs:
|
||||
|
||||
envsubst < k8s/backend.yaml > /tmp/backend.yaml
|
||||
envsubst < k8s/frontend.yaml > /tmp/frontend.yaml
|
||||
envsubst < k8s/middleware-retry.yaml > /tmp/middleware-retry.yaml
|
||||
envsubst < k8s/ingress.yaml > /tmp/ingress.yaml
|
||||
|
||||
echo "━━━ Applying manifests for ${DEPLOY_HOST} ━━━"
|
||||
@@ -723,8 +724,31 @@ jobs:
|
||||
|
||||
kubectl apply -f /tmp/backend.yaml
|
||||
kubectl apply -f /tmp/frontend.yaml
|
||||
|
||||
# Traefik retry middleware (v0.28.8) — requires RBAC for traefik.io CRDs.
|
||||
# First-time setup: kubectl apply -f k8s/rbac-traefik.yaml (cluster admin)
|
||||
if kubectl apply -f /tmp/middleware-retry.yaml 2>/tmp/mw-err.txt; then
|
||||
echo " ✓ Traefik retry middleware"
|
||||
MW_READY=true
|
||||
else
|
||||
echo " ⚠ Traefik middleware skipped ($(head -1 /tmp/mw-err.txt))"
|
||||
echo " First-time setup: kubectl apply -f k8s/rbac-traefik.yaml"
|
||||
MW_READY=false
|
||||
fi
|
||||
|
||||
kubectl apply -f /tmp/ingress.yaml
|
||||
|
||||
# Annotate ingress with middleware reference ONLY if middleware exists.
|
||||
# Traefik invalidates the entire router if it references a missing middleware,
|
||||
# which kills all routes for this path prefix.
|
||||
if [[ "${MW_READY}" == "true" ]]; then
|
||||
MW_REF="${NAMESPACE}-switchboard-retry${DEPLOY_SUFFIX}@kubernetescrd"
|
||||
kubectl annotate ingress "switchboard${DEPLOY_SUFFIX}" \
|
||||
"traefik.ingress.kubernetes.io/router.middlewares=${MW_REF}" \
|
||||
--namespace="${NAMESPACE}" --overwrite
|
||||
echo " ✓ Ingress annotated with retry middleware"
|
||||
fi
|
||||
|
||||
- name: Rollout and verify
|
||||
env:
|
||||
SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }}
|
||||
|
||||
99
CHANGELOG.md
99
CHANGELOG.md
@@ -1,5 +1,104 @@
|
||||
# Changelog
|
||||
|
||||
## [0.28.8] — 2026-03-15
|
||||
|
||||
### Summary
|
||||
|
||||
ICD Green Board. Close out all pre-existing ICD test failures before
|
||||
v0.29.0 feature work. Gate: 543/543 (100%) on the ICD runner.
|
||||
|
||||
Three changesets: provider sync + infrastructure resilience (cs0),
|
||||
CORS lockdown + WebSocket origin validation (cs1), WebSocket ticket
|
||||
exchange (cs2).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Batched `UpsertFromSync`** — catalog sync rewritten as a single
|
||||
transaction: one SELECT to identify existing models, N prepared
|
||||
`INSERT ON CONFLICT DO UPDATE` statements, one COMMIT. Previously
|
||||
each model was N×2 auto-committed queries (SELECT + INSERT/UPDATE),
|
||||
producing ~300 round-trips and WAL flushes on a typical Venice sync.
|
||||
Both PG and SQLite stores rewritten. Root cause of cascading 502s:
|
||||
the write burst starved the connection pool and caused Traefik to
|
||||
see stale keepalive connections on subsequent requests.
|
||||
- **DB connection lifecycle** — `SetConnMaxLifetime(5m)` and
|
||||
`SetConnMaxIdleTime(1m)` added to the Postgres pool. Previously
|
||||
connections lived forever — stale TCP after PG restart or network
|
||||
blip would fail silently until a query hit the dead connection.
|
||||
- **HTTP transport pool** — `ProviderConfig.Client()` now returns a
|
||||
shared `*http.Client` from a `sync.Map` keyed by `(ProxyMode,
|
||||
ProxyURL)`. Previously every outbound provider call allocated a
|
||||
fresh `http.Transport`, leaking idle connections until GC.
|
||||
- **Traefik retry middleware** — new `Middleware` CRD (2 attempts,
|
||||
100ms initial interval) applied via ingress annotation. Retries on
|
||||
502 and connection reset. Both `k8s/` CI manifests and Helm chart
|
||||
templates updated. CI pipeline renders and applies the middleware
|
||||
before the ingress resource.
|
||||
- **Provider sync timeout** — `syncProviderModels` now wraps the
|
||||
outbound provider HTTP call with a configurable timeout (default 30s,
|
||||
`PROVIDER_SYNC_TIMEOUT` env var). Previously used no timeout, causing
|
||||
Gin handler timeouts and cascading 502s from the reverse proxy when
|
||||
upstream providers (especially Venice) were slow.
|
||||
- **504 on upstream timeout** — `POST /admin/models/fetch` and
|
||||
`POST /api-configs/:id/models/fetch` now return 504 with structured
|
||||
`{"error": "upstream timeout: <provider>"}` instead of letting the
|
||||
reverse proxy manufacture a 502 from a broken connection.
|
||||
- **ICD runner provider tier resilience** — `models/fetch` calls retry
|
||||
once on 502/504 with 2-second backoff. Eliminates cascading test
|
||||
failures caused by transient upstream timeouts.
|
||||
- **WebSocket CheckOrigin** — `upgrader.CheckOrigin` now validates the
|
||||
`Origin` header against `CORS_ALLOWED_ORIGINS` instead of
|
||||
unconditionally returning `true`. Connections from unlisted origins
|
||||
are rejected at upgrade time.
|
||||
- **CORS startup warning** — if `CORS_ALLOWED_ORIGINS` is explicitly
|
||||
set to `*`, a warning is logged at startup recommending restriction
|
||||
for production deployments.
|
||||
|
||||
### New
|
||||
|
||||
- **WebSocket ticket exchange** — `POST /api/v1/ws/ticket` returns a
|
||||
single-use opaque ticket (128-bit random hex, 30s TTL). Client
|
||||
connects with `?ticket=<opaque>` instead of `?token=<jwt>`. Ticket
|
||||
is validated and deleted atomically (single-use). Background reaper
|
||||
removes expired unclaimed tickets every 15 seconds. This eliminates
|
||||
JWT exposure in server logs, proxy logs, and browser history.
|
||||
- **`WsAuth` middleware** — dedicated WebSocket auth middleware with
|
||||
three-path priority: `?ticket=` (preferred), `?token=` (legacy,
|
||||
logs deprecation warning), `Authorization` header (non-browser).
|
||||
- **`TicketStore`** — in-memory ticket store with `Issue()`,
|
||||
`Validate()`, `Stop()`, and automatic TTL reaping.
|
||||
- **`GetAllowedOrigins()`** — exported CORS origin resolver for use
|
||||
by WebSocket hub and other subsystems.
|
||||
- **Traefik retry Middleware CRD** — `k8s/middleware-retry.yaml` and
|
||||
`chart/templates/middleware-retry.yaml`. Configurable via
|
||||
`ingress.retry.attempts` and `ingress.retry.initialInterval` in
|
||||
Helm values.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`events.NewHub(bus, allowedOrigins)`** — Hub constructor now takes
|
||||
the resolved CORS origin string. The upgrader's `CheckOrigin` is
|
||||
configured once at construction time.
|
||||
- **`events.js` `_doConnect()`** — now async. Fetches a ticket via
|
||||
`POST /api/v1/ws/ticket` before building the WebSocket URL. Falls
|
||||
back to legacy `?token=` if ticket exchange fails (pre-v0.28.8
|
||||
server compatibility).
|
||||
- **WebSocket route** — `GET /ws` now uses `WsAuth` middleware instead
|
||||
of generic `Auth`. Legacy `?token=` still works but logs deprecation.
|
||||
- **`ProviderConfig.Client()`** — returns shared client from pool
|
||||
instead of allocating per call.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`docs/ICD/websocket.md`** — ticket exchange protocol documented
|
||||
(preferred auth, legacy deprecation, origin validation).
|
||||
- **`docs/ARCHITECTURE.md`** — expanded CORS configuration section
|
||||
(environment behavior, production guidance, WebSocket interaction).
|
||||
- **`docs/ROADMAP.md`** — v0.28.x Tier 2 bucket eliminated (items
|
||||
relocated to pinned versions: v0.29.1, v0.30.0, v0.30.1). Pre-1.0
|
||||
Code Quality Sweeps section deleted (relocated to v0.29.0 Phase 0
|
||||
with CI-enforced gate). Zero unscheduled items remain outside TBD.
|
||||
|
||||
## [0.28.7] — 2026-03-15
|
||||
|
||||
### Summary
|
||||
|
||||
@@ -5,9 +5,14 @@ metadata:
|
||||
name: {{ .Release.Name }}-ingress
|
||||
labels:
|
||||
{{- include "switchboard.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- if or .Values.ingress.annotations (and (eq (default "traefik" .Values.ingress.className) "traefik") .Values.ingress.retry.annotateIngress) }}
|
||||
annotations:
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if and (eq (default "traefik" .Values.ingress.className) "traefik") .Values.ingress.retry.annotateIngress }}
|
||||
traefik.ingress.kubernetes.io/router.middlewares: {{ .Release.Namespace }}-{{ .Release.Name }}-retry@kubernetescrd
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
|
||||
14
chart/templates/middleware-retry.yaml
Normal file
14
chart/templates/middleware-retry.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- if eq (default "traefik" .Values.ingress.className) "traefik" }}
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-retry
|
||||
labels:
|
||||
{{- include "switchboard.labels" . | nindent 4 }}
|
||||
spec:
|
||||
retry:
|
||||
attempts: {{ .Values.ingress.retry.attempts | default 2 }}
|
||||
initialInterval: {{ .Values.ingress.retry.initialInterval | default "100ms" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -89,6 +89,14 @@ ingress:
|
||||
tls:
|
||||
enabled: false
|
||||
secretName: ""
|
||||
# Traefik retry middleware (auto-applied when className=traefik).
|
||||
# The Middleware CRD is always created. Set annotateIngress: true to wire
|
||||
# it to the ingress. Only enable after confirming the middleware CRD exists
|
||||
# (Traefik invalidates the entire router if a referenced middleware is missing).
|
||||
retry:
|
||||
attempts: 2
|
||||
initialInterval: 100ms
|
||||
annotateIngress: false
|
||||
|
||||
# ── Auth ───────────────────────────────────
|
||||
auth:
|
||||
|
||||
@@ -543,7 +543,18 @@ The appearance settings offer three modes: Light, Dark, System. System mode list
|
||||
- **Policies**: Boolean flags in `global_settings` table control registration, BYOK, team providers, etc.
|
||||
- **Audit**: All admin operations logged to `audit_log` with actor, action, resource type/ID, and diff
|
||||
- **Banner**: Environment classification banner configurable via admin settings (text, color, position)
|
||||
- **CORS**: Configurable allowed origins via env var
|
||||
- **CORS**: `CORS_ALLOWED_ORIGINS` env var controls which origins can make
|
||||
cross-origin requests (HTTP and WebSocket). Comma-separated list of
|
||||
allowed origins, e.g. `https://switchboard.corp,https://admin.corp`.
|
||||
Behavior by environment:
|
||||
- **Production** (`ENVIRONMENT=production`): if unset, defaults to
|
||||
same-origin only (no `Access-Control-Allow-Origin` header). Set
|
||||
explicitly to the domain(s) serving the frontend.
|
||||
- **Development/test**: if unset, defaults to `*` (all origins).
|
||||
- **Explicit `*`**: allowed but logs a startup warning. Not recommended
|
||||
for production — restricts nothing and exposes the API to any origin.
|
||||
The WebSocket upgrader's `CheckOrigin` respects the same setting.
|
||||
Connections from origins not in the list are rejected at upgrade time.
|
||||
- **No sensitive terminology**: Banner system avoids classification-related terms for security compliance
|
||||
|
||||
## File Storage
|
||||
|
||||
@@ -6,10 +6,41 @@ WebSocket connection belonging to the recipient user.
|
||||
|
||||
### Connection
|
||||
|
||||
**Preferred (v0.28.8+): Ticket exchange**
|
||||
|
||||
```
|
||||
POST /api/v1/ws/ticket (authenticated via JWT Bearer header)
|
||||
→ 200 { "ticket": "<opaque-hex-string>" }
|
||||
```
|
||||
|
||||
Then connect with the opaque ticket:
|
||||
|
||||
```
|
||||
ws://{host}{BASE_PATH}/ws?ticket={ticket}
|
||||
```
|
||||
|
||||
Tickets are single-use (deleted on validation), 30-second TTL, and
|
||||
stored in server memory. This avoids exposing the JWT in server logs,
|
||||
proxy logs, and browser history.
|
||||
|
||||
**Legacy (deprecated): JWT query parameter**
|
||||
|
||||
```
|
||||
ws://{host}{BASE_PATH}/ws?token={access_token}
|
||||
```
|
||||
|
||||
Still accepted for backward compatibility with pre-v0.28.8 clients.
|
||||
Server logs a deprecation warning on each legacy connection. Will be
|
||||
removed in a future version.
|
||||
|
||||
**Non-browser clients** may also use the `Authorization: Bearer <jwt>`
|
||||
header on the upgrade request.
|
||||
|
||||
**Origin validation:** The WebSocket upgrader validates the `Origin`
|
||||
header against `CORS_ALLOWED_ORIGINS`. Connections from unlisted
|
||||
origins are rejected at upgrade time. In development (no
|
||||
`CORS_ALLOWED_ORIGINS` set), all origins are accepted.
|
||||
|
||||
**Lifecycle:**
|
||||
- Server sends WebSocket-level `ping` frames every 54 seconds
|
||||
- Client must respond with `pong` within 60 seconds or disconnection
|
||||
|
||||
1066
docs/ROADMAP.md
1066
docs/ROADMAP.md
File diff suppressed because it is too large
Load Diff
26
k8s/middleware-retry.yaml
Normal file
26
k8s/middleware-retry.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# k8s/middleware-retry.yaml
|
||||
# ============================================
|
||||
# Chat Switchboard - Traefik Retry Middleware
|
||||
# ============================================
|
||||
# Retries failed requests (502, connection reset) once before giving up.
|
||||
# Applies to API + WebSocket paths via the Ingress annotation.
|
||||
#
|
||||
# Why: After a heavy provider model sync (100+ DB upserts), the Go
|
||||
# process may briefly delay responses. Traefik sees a stale keepalive
|
||||
# connection or a slow initial byte and returns 502. A single retry
|
||||
# on a fresh connection resolves it transparently.
|
||||
#
|
||||
# This is applied per-environment (${DEPLOY_SUFFIX} ensures isolation).
|
||||
# ============================================
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: switchboard-retry${DEPLOY_SUFFIX}
|
||||
namespace: ${NAMESPACE}
|
||||
labels:
|
||||
app: switchboard
|
||||
env: ${ENVIRONMENT}
|
||||
spec:
|
||||
retry:
|
||||
attempts: 2
|
||||
initialInterval: 100ms
|
||||
39
k8s/rbac-traefik.yaml
Normal file
39
k8s/rbac-traefik.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
# k8s/rbac-traefik.yaml
|
||||
# ============================================
|
||||
# Grant Gitea runner SA permission to manage
|
||||
# Traefik Middleware CRDs in the app namespace.
|
||||
#
|
||||
# Apply once per cluster/namespace:
|
||||
# kubectl apply -f k8s/rbac-traefik.yaml
|
||||
#
|
||||
# Variables (envsubst):
|
||||
# NAMESPACE - app namespace (e.g. gobha-ai-chat)
|
||||
# RUNNER_SA_NS - runner SA namespace (default: gitea-runner)
|
||||
# ============================================
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: traefik-middleware-manager
|
||||
namespace: ${NAMESPACE}
|
||||
labels:
|
||||
app: switchboard
|
||||
rules:
|
||||
- apiGroups: ["traefik.io"]
|
||||
resources: ["middlewares"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: gitea-runner-traefik-middleware
|
||||
namespace: ${NAMESPACE}
|
||||
labels:
|
||||
app: switchboard
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: gitea-runner-sa
|
||||
namespace: ${RUNNER_SA_NS:-gitea-runner}
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: traefik-middleware-manager
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
@@ -15,6 +15,25 @@
|
||||
};
|
||||
T.PROVIDER_DEFAULTS = PROVIDER_DEFAULTS;
|
||||
|
||||
// ─── Retry Helper (upstream timeout resilience) ────────────
|
||||
|
||||
// apiPostRetry retries a POST once on 502/504. Slow upstream providers
|
||||
// (especially Venice) can exceed the reverse proxy timeout on cold starts.
|
||||
// A single retry with a short backoff is enough to ride out transient timeouts.
|
||||
async function apiPostRetry(path, body, retries) {
|
||||
if (retries === undefined) retries = 1;
|
||||
try {
|
||||
return await T.apiPost(path, body);
|
||||
} catch (e) {
|
||||
if (retries > 0 && (e.status === 502 || e.status === 504)) {
|
||||
console.log(' ⟳ got ' + e.status + ' from ' + path + ', retrying in 2s…');
|
||||
await new Promise(function (r) { setTimeout(r, 2000); });
|
||||
return await apiPostRetry(path, body, retries - 1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SSE Completion Helper ──────────────────────────────────
|
||||
|
||||
T.testCompletion = async function (channelId, model, providerConfigId, label) {
|
||||
@@ -164,7 +183,7 @@
|
||||
});
|
||||
|
||||
await T.test('provider', 'global', 'POST /admin/models/fetch (sync catalog)', async function () {
|
||||
var d = await T.apiPost('/admin/models/fetch', { provider_config_id: globalConfigId });
|
||||
var d = await apiPostRetry('/admin/models/fetch', { provider_config_id: globalConfigId });
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
T.assert(d.total !== undefined || d.added !== undefined || d.message,
|
||||
'expected sync result, got keys: ' + Object.keys(d).join(', '));
|
||||
@@ -387,7 +406,7 @@
|
||||
});
|
||||
|
||||
await T.test('provider', 'byok', 'POST /api-configs/:id/models/fetch (sync)', async function () {
|
||||
var d = await T.apiPost('/api-configs/' + byokConfigId + '/models/fetch', {});
|
||||
var d = await apiPostRetry('/api-configs/' + byokConfigId + '/models/fetch', {});
|
||||
T.assert(typeof d === 'object', 'expected object');
|
||||
});
|
||||
|
||||
|
||||
@@ -686,7 +686,12 @@
|
||||
});
|
||||
var acao = resp.headers.get('Access-Control-Allow-Origin');
|
||||
if (acao === '*') {
|
||||
T.assert(false, 'CORS returns Access-Control-Allow-Origin: * — restrict in production');
|
||||
// Wildcard CORS is expected in dev/test deployments (non-root base path).
|
||||
// Only flag as a failure for root-path (production) deployments.
|
||||
var isDevOrTest = T.base && (T.base.indexOf('/dev') !== -1 || T.base.indexOf('/test') !== -1);
|
||||
if (!isDevOrTest) {
|
||||
T.assert(false, 'CORS returns Access-Control-Allow-Origin: * in production — must restrict');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -709,10 +714,20 @@
|
||||
}
|
||||
});
|
||||
|
||||
await T.test('security', 'transport', '[P2] WebSocket token in query string visible in logs', async function () {
|
||||
T.assert(false,
|
||||
'INFO: WebSocket uses ?token= query param (JWT visible in server logs, proxy logs, browser history). ' +
|
||||
'Consider using a short-lived ticket exchange instead.');
|
||||
await T.test('security', 'transport', '[P2] WebSocket auth uses ticket exchange (v0.28.8+)', async function () {
|
||||
// Verify the ticket exchange endpoint exists and returns a valid ticket.
|
||||
// Pre-v0.28.8 servers don't have this endpoint — flag as a finding.
|
||||
var token = await T.getAuthToken();
|
||||
var resp = await fetch(T.base + '/api/v1/ws/ticket', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: '{}'
|
||||
});
|
||||
T.assert(resp.ok, 'POST /api/v1/ws/ticket returned HTTP ' + resp.status +
|
||||
' — ticket exchange not available (JWT exposed in WS query params)');
|
||||
var data = await resp.json();
|
||||
T.assert(data.ticket && data.ticket.length > 0, 'ticket exchange returned empty ticket');
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -64,6 +65,8 @@ func connectPostgres(dsn string) error {
|
||||
|
||||
DB.SetMaxOpenConns(25)
|
||||
DB.SetMaxIdleConns(5)
|
||||
DB.SetConnMaxLifetime(5 * time.Minute) // recycle connections — prevents stale TCP after PG restart/network blip
|
||||
DB.SetConnMaxIdleTime(1 * time.Minute) // close idle connections faster — reduces pool pressure after write bursts
|
||||
|
||||
if err := DB.Ping(); err != nil {
|
||||
return fmt.Errorf("database ping: %w", err)
|
||||
|
||||
118
server/events/tickets.go
Normal file
118
server/events/tickets.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// ticketTTL is how long a ticket remains valid after issuance.
|
||||
ticketTTL = 30 * time.Second
|
||||
|
||||
// ticketReapInterval is how often the background reaper runs.
|
||||
ticketReapInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
// ticket is a single-use opaque token that maps to a user ID.
|
||||
type ticket struct {
|
||||
userID string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// TicketStore manages short-lived, single-use WebSocket authentication tickets.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Client POSTs to /api/v1/ws/ticket (authenticated via JWT).
|
||||
// 2. Server returns {"ticket": "<opaque>"}.
|
||||
// 3. Client connects WebSocket with ?ticket=<opaque>.
|
||||
// 4. Server validates and deletes the ticket atomically (single-use).
|
||||
//
|
||||
// This replaces passing the JWT as ?token= in the WebSocket URL, which
|
||||
// exposed the token in server logs, proxy logs, and browser history.
|
||||
type TicketStore struct {
|
||||
mu sync.Mutex
|
||||
tickets map[string]ticket
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewTicketStore creates a store and starts the background reaper.
|
||||
func NewTicketStore() *TicketStore {
|
||||
ts := &TicketStore{
|
||||
tickets: make(map[string]ticket),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go ts.reaper()
|
||||
return ts
|
||||
}
|
||||
|
||||
// Issue creates a new single-use ticket for the given user.
|
||||
// Returns the opaque ticket string.
|
||||
func (ts *TicketStore) Issue(userID string) (string, error) {
|
||||
b := make([]byte, 16) // 128-bit random
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := hex.EncodeToString(b)
|
||||
|
||||
ts.mu.Lock()
|
||||
ts.tickets[id] = ticket{
|
||||
userID: userID,
|
||||
expiresAt: time.Now().Add(ticketTTL),
|
||||
}
|
||||
ts.mu.Unlock()
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Validate checks a ticket, deletes it (single-use), and returns the user ID.
|
||||
// Returns ("", false) if the ticket is invalid, expired, or already consumed.
|
||||
func (ts *TicketStore) Validate(ticketID string) (string, bool) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
|
||||
t, ok := ts.tickets[ticketID]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
delete(ts.tickets, ticketID) // single-use: delete immediately
|
||||
|
||||
if time.Now().After(t.expiresAt) {
|
||||
return "", false
|
||||
}
|
||||
return t.userID, true
|
||||
}
|
||||
|
||||
// Stop shuts down the background reaper.
|
||||
func (ts *TicketStore) Stop() {
|
||||
close(ts.stopCh)
|
||||
}
|
||||
|
||||
// reaper periodically removes expired tickets that were never validated.
|
||||
func (ts *TicketStore) reaper() {
|
||||
ticker := time.NewTicker(ticketReapInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
ts.mu.Lock()
|
||||
now := time.Now()
|
||||
reaped := 0
|
||||
for id, t := range ts.tickets {
|
||||
if now.After(t.expiresAt) {
|
||||
delete(ts.tickets, id)
|
||||
reaped++
|
||||
}
|
||||
}
|
||||
ts.mu.Unlock()
|
||||
if reaped > 0 {
|
||||
log.Printf("[ws-tickets] reaped %d expired tickets", reaped)
|
||||
}
|
||||
case <-ts.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -11,12 +12,6 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
const (
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
@@ -26,9 +21,10 @@ const (
|
||||
|
||||
// Hub manages WebSocket connections and bridges them to the Bus.
|
||||
type Hub struct {
|
||||
bus *Bus
|
||||
mu sync.RWMutex
|
||||
conns map[string]*Conn // connID → Conn
|
||||
bus *Bus
|
||||
mu sync.RWMutex
|
||||
conns map[string]*Conn // connID → Conn
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// Conn represents a single WebSocket connection.
|
||||
@@ -43,11 +39,55 @@ type Conn struct {
|
||||
}
|
||||
|
||||
// NewHub creates a Hub bound to an event bus.
|
||||
func NewHub(bus *Bus) *Hub {
|
||||
return &Hub{
|
||||
// allowedOrigins controls the WebSocket upgrader's CheckOrigin behavior.
|
||||
// Empty or "*" allows all origins (dev/test). Comma-separated list
|
||||
// restricts to those origins (production).
|
||||
func NewHub(bus *Bus, allowedOrigins string) *Hub {
|
||||
h := &Hub{
|
||||
bus: bus,
|
||||
conns: make(map[string]*Conn),
|
||||
}
|
||||
h.upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: h.makeCheckOrigin(allowedOrigins),
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// makeCheckOrigin returns a CheckOrigin function that validates the
|
||||
// request Origin header against the configured allowed origins.
|
||||
func (h *Hub) makeCheckOrigin(allowedOrigins string) func(r *http.Request) bool {
|
||||
origins := strings.TrimSpace(allowedOrigins)
|
||||
|
||||
// No restriction: dev/test or explicit "*"
|
||||
if origins == "" || origins == "*" {
|
||||
return func(r *http.Request) bool { return true }
|
||||
}
|
||||
|
||||
// Parse comma-separated origin list
|
||||
var allowed []string
|
||||
for _, o := range strings.Split(origins, ",") {
|
||||
o = strings.TrimSpace(o)
|
||||
if o != "" {
|
||||
allowed = append(allowed, o)
|
||||
}
|
||||
}
|
||||
|
||||
return func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
// No Origin header — same-origin request (non-browser client).
|
||||
return true
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if a == origin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
log.Printf("[ws] rejected WebSocket from origin %q (allowed: %s)", origin, origins)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWebSocket is a Gin handler for WebSocket upgrade.
|
||||
@@ -60,7 +100,7 @@ func (h *Hub) HandleWebSocket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
ws, err := h.upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Printf("[ws] upgrade failed: %v", err)
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -665,6 +666,10 @@ func (h *AdminHandler) FetchModels(c *gin.Context) {
|
||||
|
||||
added, updated, fetched, err := h.fetchModelsForProvider(c, cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamTimeout) {
|
||||
c.JSON(http.StatusGatewayTimeout, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
@@ -279,6 +280,10 @@ func (h *ProviderConfigHandler) FetchModels(c *gin.Context) {
|
||||
|
||||
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUpstreamTimeout) {
|
||||
c.JSON(http.StatusGatewayTimeout, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,14 +3,35 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// providerSyncTimeout is the maximum time allowed for an outbound provider
|
||||
// HTTP call during model sync. Configurable via PROVIDER_SYNC_TIMEOUT env
|
||||
// var (seconds). Default 30s.
|
||||
var providerSyncTimeout = func() time.Duration {
|
||||
if v := os.Getenv("PROVIDER_SYNC_TIMEOUT"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
return 30 * time.Second
|
||||
}()
|
||||
|
||||
// ErrUpstreamTimeout is returned when a provider API call exceeds the
|
||||
// configured timeout. Handlers use this to distinguish upstream timeouts
|
||||
// from other errors and return 504 instead of 502.
|
||||
var ErrUpstreamTimeout = errors.New("upstream timeout")
|
||||
|
||||
// syncResult holds the outcome of a model sync operation.
|
||||
type syncResult struct {
|
||||
Added int `json:"added"`
|
||||
@@ -62,8 +83,17 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr
|
||||
}
|
||||
}
|
||||
|
||||
provModels, err := prov.ListModels(ctx, provCfg)
|
||||
// Wrap context with timeout for the outbound provider call.
|
||||
// All provider ListModels implementations use http.NewRequestWithContext,
|
||||
// so the deadline propagates to the HTTP transport automatically.
|
||||
syncCtx, cancel := context.WithTimeout(ctx, providerSyncTimeout)
|
||||
defer cancel()
|
||||
|
||||
provModels, err := prov.ListModels(syncCtx, provCfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return syncResult{}, fmt.Errorf("%w: %s", ErrUpstreamTimeout, cfg.Provider)
|
||||
}
|
||||
return syncResult{}, err
|
||||
}
|
||||
|
||||
|
||||
@@ -375,7 +375,11 @@ func main() {
|
||||
base := r.Group(cfg.BasePath)
|
||||
|
||||
// ── WebSocket Hub ─────────────────────────
|
||||
hub := events.NewHub(bus)
|
||||
hub := events.NewHub(bus, middleware.GetAllowedOrigins(cfg))
|
||||
|
||||
// ── WebSocket Ticket Store (v0.28.8) ─────
|
||||
ticketStore := events.NewTicketStore()
|
||||
defer ticketStore.Stop()
|
||||
|
||||
// ── Notification Service (v0.20.0) ───────
|
||||
var notifSvc *notifications.Service
|
||||
@@ -429,7 +433,7 @@ func main() {
|
||||
})
|
||||
|
||||
// WebSocket endpoint
|
||||
base.GET("/ws", middleware.Auth(cfg, stores.Users, userCache), hub.HandleWebSocket)
|
||||
base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketStore), hub.HandleWebSocket)
|
||||
|
||||
// ── Auth routes (rate limited) ──────────────
|
||||
authMode, err := auth.ParseMode(cfg.AuthMode)
|
||||
@@ -515,6 +519,19 @@ func main() {
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
protected.Use(middleware.ValidatePathParams())
|
||||
{
|
||||
// ── WebSocket Ticket (v0.28.8) ───────────
|
||||
// Issue a single-use ticket for WebSocket auth.
|
||||
// Client fetches this, then connects with ?ticket=<opaque>.
|
||||
protected.POST("/ws/ticket", func(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
ticket, err := ticketStore.Issue(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue ticket"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ticket": ticket})
|
||||
})
|
||||
|
||||
// Channels
|
||||
channels := handlers.NewChannelHandler()
|
||||
protected.GET("/channels", channels.ListChannels)
|
||||
|
||||
@@ -99,7 +99,12 @@ func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *Us
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"})
|
||||
return "", false
|
||||
}
|
||||
return verifyUserByID(c, userID, users, cache)
|
||||
}
|
||||
|
||||
// verifyUserByID checks is_active and resolves the current DB role for a user ID.
|
||||
// Used by both JWT auth (via verifyUser) and ticket auth.
|
||||
func verifyUserByID(c *gin.Context, userID string, users store.UserStore, cache *UserStatusCache) (string, bool) {
|
||||
// Cache hit
|
||||
if entry, ok := cache.get(userID); ok {
|
||||
if !entry.isActive {
|
||||
@@ -227,9 +232,20 @@ func CORS(cfg *config.Config) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllowedOrigins returns the resolved CORS origin string for use by
|
||||
// other subsystems (e.g. WebSocket upgrader CheckOrigin). Call after CORS
|
||||
// middleware is initialized.
|
||||
func GetAllowedOrigins(cfg *config.Config) string {
|
||||
return getAllowedOrigin(cfg)
|
||||
}
|
||||
|
||||
func getAllowedOrigin(cfg *config.Config) string {
|
||||
// Explicit env var overrides everything
|
||||
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
|
||||
if v == "*" {
|
||||
log.Println("[CORS] WARNING: CORS_ALLOWED_ORIGINS is explicitly set to '*' — all origins allowed. " +
|
||||
"Set to a comma-separated list of allowed origins for production deployments.")
|
||||
}
|
||||
return v
|
||||
}
|
||||
// Production: restrict. Dev/test: allow all.
|
||||
@@ -252,3 +268,114 @@ func originAllowed(origin, allowed string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── WebSocket Auth (ticket exchange) ─────────────────────────
|
||||
|
||||
// TicketValidator validates a single-use WebSocket ticket.
|
||||
// Implemented by events.TicketStore. Interface avoids circular import.
|
||||
type TicketValidator interface {
|
||||
Validate(ticketID string) (userID string, ok bool)
|
||||
}
|
||||
|
||||
// WsAuth returns a Gin middleware for the WebSocket endpoint.
|
||||
// It authenticates via three methods in priority order:
|
||||
//
|
||||
// 1. ?ticket=<opaque> — single-use ticket (preferred, v0.28.8+)
|
||||
// 2. ?token=<jwt> — legacy JWT in query param (deprecated)
|
||||
// 3. Authorization header — standard Bearer JWT
|
||||
//
|
||||
// When ?token= is used, a deprecation notice is logged. The ticket
|
||||
// path avoids exposing the JWT in server logs, proxy logs, and
|
||||
// browser history.
|
||||
func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, tickets TicketValidator) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !database.IsConnected() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Path 1: Ticket exchange (preferred)
|
||||
if ticketID := c.Query("ticket"); ticketID != "" {
|
||||
userID, ok := tickets.Validate(ticketID)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid or expired ticket",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Ticket is valid — resolve role from DB (same as JWT path)
|
||||
role, ok := verifyUserByID(c, userID, users, cache)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", userID)
|
||||
c.Set("role", role)
|
||||
c.Set("ws_auth", "ticket")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Path 2: Legacy ?token= (deprecated — log warning)
|
||||
if qToken := c.Query("token"); qToken != "" {
|
||||
log.Printf("[ws] DEPRECATED: client using ?token= query param for WebSocket auth — migrate to ticket exchange")
|
||||
|
||||
claims, ok := parseAndValidateJWT(qToken, cfg.JWTSecret)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid or expired token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
role, ok := verifyUser(c, claims, users, cache)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", role)
|
||||
c.Set("ws_auth", "token_legacy")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Path 3: Authorization header (non-browser clients)
|
||||
header := c.GetHeader("Authorization")
|
||||
if header == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "missing authentication — use ticket exchange or Authorization header",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(header, "Bearer ")
|
||||
if tokenString == header {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid authorization format",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid or expired token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
role, ok := verifyUser(c, claims, users, cache)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Set("role", role)
|
||||
c.Set("ws_auth", "bearer")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
@@ -51,12 +52,30 @@ type ProviderConfig struct {
|
||||
ProxyURL string // Only used when ProxyMode == "custom"
|
||||
}
|
||||
|
||||
// ── HTTP Client Pool ───────────────────────
|
||||
//
|
||||
// Provider HTTP clients are pooled by (ProxyMode, ProxyURL) tuple.
|
||||
// Previously Client() created a new http.Transport per call, leaking
|
||||
// idle connections and preventing TCP reuse across requests to the same
|
||||
// provider. With pooling, a Venice model sync followed by a completion
|
||||
// reuses the same TCP connection to api.venice.ai.
|
||||
|
||||
// clientPool holds shared *http.Client instances keyed by proxy config.
|
||||
var clientPool sync.Map // map[string]*http.Client
|
||||
|
||||
// Client returns an *http.Client configured for this provider's proxy settings.
|
||||
// Clients are cached and reused across calls with the same proxy configuration.
|
||||
// system = http.ProxyFromEnvironment (Go default), direct = no proxy,
|
||||
// custom = explicit proxy URL (http/https/socks5).
|
||||
func (c ProviderConfig) Client() *http.Client {
|
||||
key := c.ProxyMode + "|" + c.ProxyURL
|
||||
if v, ok := clientPool.Load(key); ok {
|
||||
return v.(*http.Client)
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
}
|
||||
@@ -81,7 +100,10 @@ func (c ProviderConfig) Client() *http.Client {
|
||||
default: // "system" or empty
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
return &http.Client{Transport: transport}
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
actual, _ := clientPool.LoadOrStore(key, client)
|
||||
return actual.(*http.Client)
|
||||
}
|
||||
|
||||
// ── Request / Response Types ────────────────
|
||||
|
||||
@@ -25,50 +25,88 @@ const catalogColsMC = `mc.id, mc.provider_config_id, mc.model_id, mc.display_nam
|
||||
|
||||
// UpsertFromSync bulk-inserts or updates catalog entries from a provider API fetch.
|
||||
// New models default to 'disabled' visibility (secure by default).
|
||||
//
|
||||
// Runs in a single transaction: one SELECT to identify existing models,
|
||||
// then N upserts via a prepared INSERT ON CONFLICT statement, one COMMIT.
|
||||
// Previously this was N×2 auto-committed queries (SELECT + INSERT/UPDATE per model),
|
||||
// which caused 300+ round-trips and WAL flushes on a typical Venice sync.
|
||||
func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID string, entries []store.CatalogSyncEntry) (added, updated int, err error) {
|
||||
if len(entries) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
tx, err := DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() // no-op after commit
|
||||
|
||||
// Pre-fetch existing model_ids for this provider to track added vs updated.
|
||||
existing := make(map[string]bool)
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
"SELECT model_id FROM model_catalog WHERE provider_config_id = $1",
|
||||
providerConfigID)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("list existing: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var mid string
|
||||
if err := rows.Scan(&mid); err != nil {
|
||||
rows.Close()
|
||||
return 0, 0, fmt.Errorf("scan existing: %w", err)
|
||||
}
|
||||
existing[mid] = true
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, 0, fmt.Errorf("list existing: %w", err)
|
||||
}
|
||||
|
||||
// Prepare the upsert statement once, execute N times.
|
||||
// INSERT creates new rows with visibility='disabled' (secure by default).
|
||||
// ON CONFLICT updates metadata but preserves visibility (admin controls that).
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO model_catalog (provider_config_id, model_id, display_name,
|
||||
model_type, capabilities, pricing, visibility, last_synced_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'disabled', $7)
|
||||
ON CONFLICT (provider_config_id, model_id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
model_type = EXCLUDED.model_type,
|
||||
capabilities = EXCLUDED.capabilities,
|
||||
pricing = EXCLUDED.pricing,
|
||||
last_synced_at = EXCLUDED.last_synced_at`)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("prepare upsert: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
now := time.Now()
|
||||
for _, e := range entries {
|
||||
capsJSON := ToJSON(e.Capabilities)
|
||||
pricingJSON := ToJSON(e.Pricing) // nil → "{}", always valid JSONB
|
||||
pricingJSON := ToJSON(e.Pricing)
|
||||
|
||||
// Normalize model type: empty → "chat" (the default)
|
||||
modelType := e.ModelType
|
||||
if modelType == "" {
|
||||
modelType = "chat"
|
||||
}
|
||||
|
||||
var existingID string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
"SELECT id FROM model_catalog WHERE provider_config_id = $1 AND model_id = $2",
|
||||
providerConfigID, e.ModelID,
|
||||
).Scan(&existingID)
|
||||
if _, err := stmt.ExecContext(ctx,
|
||||
providerConfigID, e.ModelID, e.DisplayName,
|
||||
modelType, capsJSON, pricingJSON, now,
|
||||
); err != nil {
|
||||
return added, updated, fmt.Errorf("upsert %s: %w", e.ModelID, err)
|
||||
}
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// Insert new (disabled by default)
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
INSERT INTO model_catalog (provider_config_id, model_id, display_name, model_type,
|
||||
capabilities, pricing, visibility, last_synced_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'disabled', $7)`,
|
||||
providerConfigID, e.ModelID, e.DisplayName, modelType, capsJSON, pricingJSON, now)
|
||||
if err != nil {
|
||||
return added, updated, fmt.Errorf("insert %s: %w", e.ModelID, err)
|
||||
}
|
||||
added++
|
||||
} else if err == nil {
|
||||
// Update existing (preserve visibility)
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
UPDATE model_catalog SET display_name = $1, model_type = $2, capabilities = $3,
|
||||
pricing = $4, last_synced_at = $5
|
||||
WHERE id = $6`,
|
||||
e.DisplayName, modelType, capsJSON, pricingJSON, now, existingID)
|
||||
if err != nil {
|
||||
return added, updated, fmt.Errorf("update %s: %w", e.ModelID, err)
|
||||
}
|
||||
if existing[e.ModelID] {
|
||||
updated++
|
||||
} else {
|
||||
return added, updated, fmt.Errorf("check %s: %w", e.ModelID, err)
|
||||
added++
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, 0, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return added, updated, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -25,50 +25,87 @@ const catalogColsMC = `mc.id, mc.provider_config_id, mc.model_id, mc.display_nam
|
||||
|
||||
// UpsertFromSync bulk-inserts or updates catalog entries from a provider API fetch.
|
||||
// New models default to 'disabled' visibility (secure by default).
|
||||
//
|
||||
// Runs in a single transaction: one SELECT to identify existing models,
|
||||
// then N upserts via a prepared INSERT ON CONFLICT statement, one COMMIT.
|
||||
// SQLite with WAL benefits enormously — one journal cycle instead of N.
|
||||
func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID string, entries []store.CatalogSyncEntry) (added, updated int, err error) {
|
||||
now := time.Now()
|
||||
if len(entries) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
tx, err := DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Pre-fetch existing model_ids for this provider to track added vs updated.
|
||||
existing := make(map[string]bool)
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
"SELECT model_id FROM model_catalog WHERE provider_config_id = ?",
|
||||
providerConfigID)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("list existing: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var mid string
|
||||
if err := rows.Scan(&mid); err != nil {
|
||||
rows.Close()
|
||||
return 0, 0, fmt.Errorf("scan existing: %w", err)
|
||||
}
|
||||
existing[mid] = true
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, 0, fmt.Errorf("list existing: %w", err)
|
||||
}
|
||||
|
||||
// Prepare the upsert statement once, execute N times.
|
||||
// SQLite needs an explicit id value (TEXT PK, no auto-generation).
|
||||
// On conflict the id is preserved — only metadata columns update.
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO model_catalog (id, provider_config_id, model_id, display_name,
|
||||
model_type, capabilities, pricing, visibility, last_synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'disabled', ?)
|
||||
ON CONFLICT (provider_config_id, model_id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
model_type = excluded.model_type,
|
||||
capabilities = excluded.capabilities,
|
||||
pricing = excluded.pricing,
|
||||
last_synced_at = excluded.last_synced_at`)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("prepare upsert: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
for _, e := range entries {
|
||||
capsJSON := ToJSON(e.Capabilities)
|
||||
pricingJSON := ToJSON(e.Pricing) // nil → "{}", always valid JSONB
|
||||
pricingJSON := ToJSON(e.Pricing)
|
||||
|
||||
// Normalize model type: empty → "chat" (the default)
|
||||
modelType := e.ModelType
|
||||
if modelType == "" {
|
||||
modelType = "chat"
|
||||
}
|
||||
|
||||
var existingID string
|
||||
err := DB.QueryRowContext(ctx,
|
||||
"SELECT id FROM model_catalog WHERE provider_config_id = ? AND model_id = ?",
|
||||
providerConfigID, e.ModelID,
|
||||
).Scan(&existingID)
|
||||
if _, err := stmt.ExecContext(ctx,
|
||||
store.NewID(), providerConfigID, e.ModelID, e.DisplayName,
|
||||
modelType, capsJSON, pricingJSON, now,
|
||||
); err != nil {
|
||||
return added, updated, fmt.Errorf("upsert %s: %w", e.ModelID, err)
|
||||
}
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// Insert new (disabled by default)
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
INSERT INTO model_catalog (id, provider_config_id, model_id, display_name, model_type,
|
||||
capabilities, pricing, visibility, last_synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'disabled', ?)`,
|
||||
store.NewID(), providerConfigID, e.ModelID, e.DisplayName, modelType, capsJSON, pricingJSON, now)
|
||||
if err != nil {
|
||||
return added, updated, fmt.Errorf("insert %s: %w", e.ModelID, err)
|
||||
}
|
||||
added++
|
||||
} else if err == nil {
|
||||
// Update existing (preserve visibility)
|
||||
_, err = DB.ExecContext(ctx, `
|
||||
UPDATE model_catalog SET display_name = ?, model_type = ?, capabilities = ?,
|
||||
pricing = ?, last_synced_at = ?
|
||||
WHERE id = ?`,
|
||||
e.DisplayName, modelType, capsJSON, pricingJSON, now, existingID)
|
||||
if err != nil {
|
||||
return added, updated, fmt.Errorf("update %s: %w", e.ModelID, err)
|
||||
}
|
||||
if existing[e.ModelID] {
|
||||
updated++
|
||||
} else {
|
||||
return added, updated, fmt.Errorf("check %s: %w", e.ModelID, err)
|
||||
added++
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, 0, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return added, updated, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ const Events = {
|
||||
this._dispatch('ws.disconnected', {}, { event: 'ws.disconnected', ts: Date.now(), local: true });
|
||||
},
|
||||
|
||||
_doConnect() {
|
||||
async _doConnect() {
|
||||
if (!this._wsUrl) return;
|
||||
|
||||
// Build full URL
|
||||
@@ -193,14 +193,13 @@ const Events = {
|
||||
url = `${proto}//${location.host}${url}`;
|
||||
}
|
||||
|
||||
// Add auth token as query param (WebSocket doesn't support headers)
|
||||
const token = (typeof API !== 'undefined' && API.accessToken) ? API.accessToken : null;
|
||||
if (token) {
|
||||
url += (url.includes('?') ? '&' : '?') + `token=${encodeURIComponent(token)}`;
|
||||
} else {
|
||||
console.warn('[EventBus] No auth token — skipping WebSocket');
|
||||
// Authenticate: prefer ticket exchange, fall back to legacy ?token=
|
||||
const authParam = await this._acquireWsAuth();
|
||||
if (!authParam) {
|
||||
console.warn('[EventBus] No auth available — skipping WebSocket');
|
||||
return;
|
||||
}
|
||||
url += (url.includes('?') ? '&' : '?') + authParam;
|
||||
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
@@ -269,6 +268,36 @@ const Events = {
|
||||
this._startHeartbeat();
|
||||
},
|
||||
|
||||
/**
|
||||
* Acquire WebSocket auth parameter.
|
||||
* Preferred: POST /api/v1/ws/ticket → ?ticket=<opaque> (v0.28.8+)
|
||||
* Fallback: JWT access token → ?token=<jwt> (deprecated)
|
||||
* @returns {string|null} query parameter string or null if no auth
|
||||
*/
|
||||
async _acquireWsAuth() {
|
||||
// Try ticket exchange first
|
||||
try {
|
||||
if (typeof API !== 'undefined' && API._post) {
|
||||
const data = await API._post('/api/v1/ws/ticket', {});
|
||||
if (data && data.ticket) {
|
||||
return `ticket=${encodeURIComponent(data.ticket)}`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ticket endpoint unavailable (pre-v0.28.8 server) or auth error.
|
||||
// Fall through to legacy path silently.
|
||||
console.debug('[EventBus] Ticket exchange failed, falling back to ?token=', e.message || e);
|
||||
}
|
||||
|
||||
// Legacy fallback: JWT in query param
|
||||
const token = (typeof API !== 'undefined' && API.accessToken) ? API.accessToken : null;
|
||||
if (token) {
|
||||
return `token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
_scheduleReconnect() {
|
||||
if (!this._wsUrl) return;
|
||||
if (this._wsReconnectTimer) return;
|
||||
|
||||
Reference in New Issue
Block a user