Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Public/admin console listener split**: the control plane can now bind admin/management routes (auth, dashboard, CRUD, settings, SwaggerUI, the SPA) to a separate address from public ingest (analytics events, error tracking, AI gateway, worker node sync, email tracking, Sentry/OTLP). Set `TEMPS_CONSOLE_ADMIN_ADDRESS=127.0.0.1:8081` (or any private interface) to enable; leave it unset for the existing single-listener behavior. Optional defense-in-depth via `TEMPS_ADMIN_ALLOWED_IPS` (comma-separated IPs/CIDRs), `TEMPS_ADMIN_ALLOWED_HOSTS` (comma-separated Host header values), and `TEMPS_ADMIN_TRUST_FORWARDED_FOR` (honor `X-Forwarded-For` only from loopback peers, anti-spoof). Denied requests on the admin gate return `404 Not Found`, not `403 Forbidden`, so probes can't fingerprint the admin surface. Each plugin classifies its own routes via the existing `configure_routes` (admin) / `configure_public_routes` (public) hooks — analytics events, session replay, performance, error tracking (Sentry + sentry-cli), email tracking, AI gateway, and the worker-facing multi-node endpoints have been split accordingly. SwaggerUI and the embedded SPA now mount on the admin listener only. See [docs/howto/admin-listener](docs/howto/admin-listener/page.mdx).
- **Paginated "visitors in segment" page**: clicking any non-page dimension row (e.g. "Chrome" in Browsers, "United States" in Countries, an event name, a referrer, a UTM value) now navigates to `/projects/:slug/analytics/segments/:dimension/:value` — a paginated list of visitors that match the segment in the selected date range, sorted by last action descending (25 per page). Rows link to the existing visitor detail page so you can see the full journey for any visitor. Powered by new optional `filter_*` query params on `GET /analytics/visitors` (`filter_country`, `filter_region`, `filter_city`, `filter_channel`, `filter_referrer`, `filter_event`, `filter_browser`, `filter_os`, `filter_device`, `filter_language`, `filter_utm_source`, `filter_utm_medium`, `filter_utm_campaign`, `filter_utm_term`, `filter_utm_content`); visitor-side filters resolve against `visitor` / `ip_geolocations` while event-side filters use an `EXISTS (SELECT 1 FROM events …)` semi-join scoped by `(project_id, visitor_id, timestamp)` so existing composite indexes (`idx_events_visitor_timestamp`, `idx_visitor_project_last_seen`) carry the query. Date filter (quick or custom) is preserved across overview → dimensions → segment visitors → back.
- **Analytics "view all" dimension pages with date-filter propagation**: every overview chart (events, referrers, browsers, operating systems, devices, locations, channels, languages, UTM source/medium/campaign/term/content) now has a **View all** button in its header that opens a dedicated `/projects/:slug/analytics/dimensions/:dimension` page. The page fetches up to the analytics API cap (100 rows) — far beyond the top 5/10 surfaced on the dashboard — and adds an inline filter input for client-side narrowing. Events list rows on the dimension page link through to the existing event detail view, which now shows a "first → last" timestamp range alongside richer per-visitor columns (visitor UUID + numeric id, device, browser, location, referrer). The active date filter (quick filter or custom range — `filter` / `from` / `to`) is preserved on every hop: overview → dimensions → event detail → back. Previously the event detail tab hardcoded "Last 24 hours" and dropped whatever range the user had selected.
- **CLI device-authorization (browser) login flow**: `bunx @temps-sdk/cli login` opens your browser to a `/cli-login/:userCode` approval page where you sign in with the same credentials, MFA, and SSO flows you use for the web UI — the CLI never prompts for a password. The CLI requests a `device_code` + short `user_code` from the new `POST /auth/cli/device/start` endpoint (best-effort `open` / `xdg-open` / `start`, with a printed URL fallback for headless / SSH / sandbox shells), and polls `POST /auth/cli/device/poll` until you approve the device. The approval page is mounted inside `ProtectedLayout` so unauthenticated users get bounced through the standard `/login` screen — no fork of the auth UI. New backing table `cli_login_sessions` tracks `device_code` / `user_code` / status, mints the API key on approval, and delivers the plaintext to the CLI exactly once before clearing it. The OAuth 2.0 device-flow status codes (`authorization_pending`, `slow_down`, `access_denied`, `expired_token`, `approved`) are honoured; `slow_down` doubles the CLI's polling interval up to a 10s cap. Set `TEMPS_NO_BROWSER=1` to skip the auto-open attempt (the URL is still printed). See [docs/howto/cli-login](docs/howto/cli-login/page.mdx).
Expand All @@ -22,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **CLI flags `--email` / `--password` / `--magic` / `--mfa` / `--device`** on `temps login`. The interactive flow is the browser device flow unconditionally; `--api-key` is preserved for headless / CI. Magic-link login through the CLI is no longer supported (magic links still work for browser logins from the web `/login` page).

### Fixed
- **GitHub App scoped token mint failures are now logged with context**: each fallible step of the GitHub App installation token flow (private key parse, JWT creation, octocrab client build, installation fetch, `access_tokens_url` parse, GitHub `access_tokens` POST) now emits an `error!` line with `installation_id` and `app_id` so a "GitHub rejected access_tokens" failure can be traced back to the specific installation. The new logs call out the two common causes — requested repo not selected on the installation, or the App lacks the requested permission — so operators stop having to re-derive context from the call site. Pure observability change; no behavior change to the token mint itself.
- **Sandbox bring-up now runs a dedicated `normalize_ownership` step on both create and recover.** The container post-start chown is factored into a separate method that does `chown -R temps:temps` on both the home volume (best-effort: warns on non-zero exit, continues) and the bind-mounted `/home/temps/workspace` (fatal with `stat`-based verification so dev-machine bind-mount backends that return EPERM for logical no-ops don't abort, but real prod permission failures do). This is the in-container defense-in-depth that complements the host-side `chown_workdir_to_sandbox_user` from beta.9 — fixes the residual "Permission denied" failures on `mkdir reports/`, `git commit`, and lockfile creation under workspace.


Expand Down
41 changes: 22 additions & 19 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 12 additions & 2 deletions crates/temps-ai-gateway/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,25 @@ impl TempsPlugin for AiGatewayPlugin {
fn configure_routes(&self, context: &PluginContext) -> Option<PluginRoutes> {
let app_state = context.require_service::<AiGatewayAppState>();

let routes = handlers::configure_gateway_routes()
.merge(handlers::configure_admin_routes())
// Admin: provider key management, usage analytics, pricing dashboard.
let routes = handlers::configure_admin_routes()
.merge(handlers::configure_usage_routes())
.merge(handlers::configure_pricing_routes())
.with_state(app_state);

Some(PluginRoutes { router: routes })
}

fn configure_public_routes(&self, context: &PluginContext) -> Option<PluginRoutes> {
let app_state = context.require_service::<AiGatewayAppState>();

// Public: the OpenAI-compatible gateway endpoints. Auth is via API key
// tokens issued to deployed apps (handled inside the handlers).
let routes = handlers::configure_gateway_routes().with_state(app_state);

Some(PluginRoutes { router: routes })
}

fn openapi_schema(&self) -> Option<OpenApi> {
let mut schema = <handlers::gateway::AiGatewayApiDoc as OpenApiTrait>::openapi();
let admin_schema = <handlers::providers::AiGatewayAdminApiDoc as OpenApiTrait>::openapi();
Expand Down
92 changes: 88 additions & 4 deletions crates/temps-analytics-backend/src/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,15 @@ async fn execute_multi(
sql: &str,
) -> Result<(), AnalyticsBackendError> {
for raw in sql.split(";\n") {
let stmt = raw.trim();
if stmt.is_empty() || stmt.starts_with("--") {
// Skip empty fragments and pure-comment fragments.
// Inline comments inside a real statement still travel with it.
// Peel leading whole-line `--` comments off each chunk before
// checking emptiness. Without this, a statement preceded by a
// header comment block looks like a "comment fragment" and gets
// silently skipped while still being recorded as applied — so
// the DDL never lands and the fan-out worker fails on missing
// tables. Inline `--` comments inside a statement are left
// intact because CH parses them as end-of-line comments.
let stmt = strip_leading_line_comments(raw).trim();
if stmt.is_empty() {
continue;
}
client.query(stmt).execute().await.map_err(|e| {
Expand All @@ -154,6 +159,22 @@ async fn execute_multi(
Ok(())
}

/// Drop leading whole-line `--` comments (and blank lines) from a SQL
/// chunk. Stops at the first non-comment line so embedded `--` inside
/// a statement is preserved.
fn strip_leading_line_comments(raw: &str) -> &str {
let mut offset = 0;
for line in raw.split_inclusive('\n') {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with("--") {
offset += line.len();
} else {
break;
}
}
&raw[offset..]
}

fn truncate(s: &str, n: usize) -> String {
if s.len() <= n {
s.to_string()
Expand All @@ -168,3 +189,66 @@ pub struct MigrationReport {
pub applied: Vec<&'static str>,
pub skipped: Vec<&'static str>,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn strips_leading_comment_block_before_ddl() {
let sql = "-- Events table: derived analytical replica.\n\
-- Sort key intentionally puts project_id first.\n\
CREATE TABLE foo (id Int64) ENGINE = MergeTree ORDER BY id";
let stripped = strip_leading_line_comments(sql).trim();
assert!(stripped.starts_with("CREATE TABLE foo"));
}

#[test]
fn preserves_inline_comments_after_first_real_line() {
let sql = "CREATE TABLE bar (\n\
-- column comment\n\
id Int64\n\
) ENGINE = MergeTree ORDER BY id";
let stripped = strip_leading_line_comments(sql);
assert!(stripped.contains("-- column comment"));
}

#[test]
fn returns_empty_for_pure_comment_chunk() {
let sql = "-- just a comment\n-- and another\n";
let stripped = strip_leading_line_comments(sql).trim();
assert!(stripped.is_empty());
}

#[test]
fn handles_blank_lines_between_comments() {
let sql = "-- header\n\
\n\
-- more header\n\
\n\
CREATE TABLE baz (id Int64) ENGINE = MergeTree ORDER BY id";
let stripped = strip_leading_line_comments(sql).trim();
assert!(stripped.starts_with("CREATE TABLE baz"));
}

/// Regression guard: every shipped CH migration must contain a real
/// DDL statement after the comment-stripping step. Catches a future
/// migration that's entirely comments before we silently record it
/// as applied with zero side-effect.
#[test]
fn every_migration_yields_at_least_one_runnable_statement() {
for migration in MIGRATIONS {
let runnable: Vec<&str> = migration
.sql
.split(";\n")
.map(|raw| strip_leading_line_comments(raw).trim())
.filter(|s| !s.is_empty())
.collect();
assert!(
!runnable.is_empty(),
"migration {} produced no runnable statements after comment strip",
migration.name
);
}
}
}
11 changes: 9 additions & 2 deletions crates/temps-analytics-events/src/handlers/events_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1030,7 +1030,7 @@ pub async fn get_dashboard_projects_analytics(
Ok(Json(result))
}

/// Configure routes for events
/// Configure admin routes for events (authenticated queries / management).
pub fn configure_routes() -> Router<Arc<AppState>> {
Router::new()
.route(
Expand Down Expand Up @@ -1079,7 +1079,14 @@ pub fn configure_routes() -> Router<Arc<AppState>> {
post(record_console_event),
)
.route("/sessions/{session_id}/events", get(get_session_events))
.route("/_temps/event", post(record_event_metrics))
}

/// Configure public ingest routes for events.
///
/// These are called by browser SDKs on customer sites and must be reachable
/// without authentication — the project is resolved from the Host header.
pub fn configure_public_routes() -> Router<Arc<AppState>> {
Router::new().route("/_temps/event", post(record_event_metrics))
}

#[derive(utoipa::OpenApi)]
Expand Down
Loading
Loading