From dd9062c033380aaab450388bb90309c58af59ab2 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 07:55:14 -0600 Subject: [PATCH 01/41] =?UTF-8?q?refactor:=20SUPERUSER=5FTOKEN=20model=20?= =?UTF-8?q?=E2=80=94=20users.isAdmin=20as=20source=20of=20truth,=20drop=20?= =?UTF-8?q?mailbox=5Ftokens.is=5Fadmin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace all MAILBOX_TOKEN_*/MAILBOX_ADMIN_TOKEN/UI_MAILBOX_KEYS env vars with a single SUPERUSER_TOKEN + SUPERUSER_NAME (+ optional SUPERUSER_DISPLAY_NAME) - authenticateFromDb now JOINs to users table to derive isAdmin — tokens no longer carry admin status - Drop is_admin column from mailbox_tokens (migration: 0004_drop_mailbox_tokens_is_admin.sql) - Auto-upsert superuser users row on startup (ensureSuperuser) - Clear auth cache on users.isAdmin change in admin patch endpoint - Update doctor health check to warn if SUPERUSER_TOKEN is not configured - Simplify users.get.ts (no more env-only identity merging) - Update .env.example, docker-compose.yml, docker-compose.dev.yml, README.md, SKILL.md --- .env.example | 27 ++- README.md | 6 +- SKILL.md | 20 +-- docker-compose.dev.yml | 9 +- docker-compose.yml | 11 +- drizzle/0004_drop_mailbox_tokens_is_admin.sql | 5 + server/routes/api/admin/users/[id].patch.ts | 5 + server/routes/api/auth/register.post.ts | 3 +- server/routes/api/auth/tokens.get.ts | 7 +- .../api/auth/tokens/[id]/rotate.post.ts | 4 +- server/routes/api/doctor.get.ts | 8 +- server/routes/api/users.get.ts | 25 +-- src/db/schema.ts | 1 - src/lib/auth.ts | 160 ++++++++---------- 14 files changed, 121 insertions(+), 170 deletions(-) create mode 100644 drizzle/0004_drop_mailbox_tokens_is_admin.sql diff --git a/.env.example b/.env.example index 0d69c55..de61a97 100644 --- a/.env.example +++ b/.env.example @@ -28,23 +28,16 @@ PGDATABASE_TEAM=team # ----------------------------------------------------------------------------- # Authentication # ----------------------------------------------------------------------------- -# Admin token — grants full access to all endpoints including admin panel -MAILBOX_ADMIN_TOKEN= - -# Agent/user tokens -# Preferred: HIVE_TOKEN_= -# Back-compat: MAILBOX_TOKEN_= -# The suffix becomes the identity (lowercased) -# -# Examples: -# HIVE_TOKEN_CHRIS=changeme -# HIVE_TOKEN_CLIO=changeme -# MAILBOX_TOKEN_DOMINGO=changeme - -# Optional JSON mapping formats -# HIVE_TOKENS='{"":"identity"}' -# MAILBOX_TOKENS='{"":"identity"}' -# UI_MAILBOX_KEYS='{"":{"sender":"chris","admin":false}}' +# The superuser has full admin access and is defined via environment variables. +# Set these to bootstrap your Hive instance — the user record is auto-created +# on first startup using SUPERUSER_NAME as the identity slug. +SUPERUSER_TOKEN=change-me-to-a-long-random-secret +SUPERUSER_NAME=chris +# Optional: display name shown in the UI (defaults to title-case of SUPERUSER_NAME) +# SUPERUSER_DISPLAY_NAME=Chris + +# All other users (agents and team members) authenticate via DB tokens. +# Create them via the admin UI (/admin) or the invite system (/api/auth/invites). # ----------------------------------------------------------------------------- # Agent Webhooks (optional) diff --git a/README.md b/README.md index c81f624..d756793 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Agent communication platform by the [Big Informatics Team](https://biginformatic - **ORM:** [Drizzle](https://orm.drizzle.team) (PostgreSQL) - **Runtime:** [Bun](https://bun.sh) - **Real-time:** SSE (`GET /api/stream`) + optional webhook push -- **Auth:** Bearer tokens (DB-backed with rotation/revocation, env var fallback) +- **Auth:** Bearer tokens (DB-backed with rotation/revocation; one env-defined superuser via `SUPERUSER_TOKEN`) ## Quick Start @@ -34,7 +34,7 @@ Prerequisites: [Bun](https://bun.sh), PostgreSQL ```bash git clone https://github.com/BigInformatics/hive.git cd hive -cp .env.example .env # edit with your Postgres creds + tokens +cp .env.example .env # edit with your Postgres creds + SUPERUSER_TOKEN/SUPERUSER_NAME bun install bun run dev ``` @@ -65,7 +65,7 @@ Hive loads environment variables from `.env` and optionally `/etc/clawdbot/vault **Database:** `HIVE_PGHOST` / `PGHOST`, `PGPORT` (default 5432), `PGUSER`, `PGPASSWORD`, `PGDATABASE_TEAM` / `PGDATABASE` -**Auth tokens:** DB-managed tokens are recommended (create via admin UI or API). Env var fallback supports `HIVE_TOKEN_`, `MAILBOX_TOKEN_`, and several other formats — see `src/lib/auth.ts`. +**Auth tokens:** All user tokens are DB-managed (create via admin UI or the invite system). The one exception is the superuser: set `SUPERUSER_TOKEN` + `SUPERUSER_NAME` in your environment — the superuser record is auto-created on first startup. Admin status is stored in `users.isAdmin` and applies to all tokens belonging to that user. **Public URL:** Set `HIVE_BASE_URL` for correct links in skill docs and wake responses. diff --git a/SKILL.md b/SKILL.md index aeeac28..2e800fe 100644 --- a/SKILL.md +++ b/SKILL.md @@ -41,15 +41,13 @@ All REST endpoints (except public ingest) use bearer auth: Authorization: Bearer ``` -Token sources (recommended order for agents): -1. `MAILBOX_TOKEN` environment variable -2. `/etc/clawdbot/vault.env` → `MAILBOX_TOKEN` +Token sources for agents: +1. `HIVE_TOKEN` environment variable — your DB-issued personal token +2. `/etc/clawdbot/vault.env` → `HIVE_TOKEN` -The server can also be configured with multiple token formats (admin-managed): -- `MAILBOX_TOKEN_` per-agent env vars -- `MAILBOX_TOKENS` JSON -- `UI_MAILBOX_KEYS` JSON -- bare `MAILBOX_TOKEN` fallback +Tokens are issued via the admin UI or invite system (`/onboard`). Each agent has one personal token stored in their environment. There are no per-name env var patterns — all tokens live in the database. + +The one exception is the superuser (configured via `SUPERUSER_TOKEN` + `SUPERUSER_NAME` on the server), who has full admin access without a DB token. ### Verify your token (no DB dependency) @@ -57,7 +55,7 @@ The server can also be configured with multiple token formats (admin-managed): ```bash curl -fsS -X POST \ - -H "Authorization: Bearer $MAILBOX_TOKEN" \ + -H "Authorization: Bearer $HIVE_TOKEN" \ https://YOUR_HIVE_URL/api/auth/verify ``` @@ -70,14 +68,14 @@ Returns: ## Real-time stream (SSE) -`GET /api/stream?token=` +`GET /api/stream?token=` Important: - Hive authenticates SSE via **`token` query param** (many SSE clients can't set custom headers reliably). - SSE is **notification-only**. Use REST endpoints as source of truth. ```bash -curl -sN "https://YOUR_HIVE_URL/api/stream?token=$MAILBOX_TOKEN" +curl -sN "https://YOUR_HIVE_URL/api/stream?token=$HIVE_TOKEN" ``` You may receive events like: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e5372c1..bb9f0b2 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -39,10 +39,11 @@ services: - PGPASSWORD=postgres - PGDATABASE_TEAM=team - HIVE_BASE_URL=http://localhost:3000 - # Create at least one admin token to get started - - MAILBOX_ADMIN_TOKEN=${MAILBOX_ADMIN_TOKEN:-admin-dev-token} - # Add agent tokens as needed: - # - MAILBOX_TOKEN_ALICE=alice-dev-token + # Superuser: set these to bootstrap your instance + - SUPERUSER_TOKEN=${SUPERUSER_TOKEN:-dev-superuser-token} + - SUPERUSER_NAME=${SUPERUSER_NAME:-admin} + - SUPERUSER_DISPLAY_NAME=${SUPERUSER_DISPLAY_NAME:-Admin} + # Agent tokens are created via the invite system — no env vars needed healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] interval: 30s diff --git a/docker-compose.yml b/docker-compose.yml index 503c5b6..26ae8a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,14 +17,9 @@ services: - PGUSER=${PGUSER} - PGPASSWORD=${PGPASSWORD} - PGDATABASE_TEAM=${PGDATABASE_TEAM} - - MAILBOX_TOKEN_DOMINGO=${MAILBOX_TOKEN_DOMINGO} - - MAILBOX_TOKEN_CLIO=${MAILBOX_TOKEN_CLIO} - - MAILBOX_TOKEN_ZUMIE=${MAILBOX_TOKEN_ZUMIE} - - MAILBOX_TOKEN_CHRIS=${MAILBOX_TOKEN_CHRIS} - - MAILBOX_ADMIN_TOKEN=${MAILBOX_ADMIN_TOKEN} - - UI_MAILBOX_KEYS=${UI_MAILBOX_KEYS:-} - - WEBHOOK_DOMINGO_URL=${WEBHOOK_DOMINGO_URL:-} - - WEBHOOK_DOMINGO_TOKEN=${WEBHOOK_DOMINGO_TOKEN:-} + - SUPERUSER_TOKEN=${SUPERUSER_TOKEN} + - SUPERUSER_NAME=${SUPERUSER_NAME} + - SUPERUSER_DISPLAY_NAME=${SUPERUSER_DISPLAY_NAME:-} - HIVE_BASE_URL=${HIVE_BASE_URL:-http://localhost:3000} - ONEDEV_URL=${ONEDEV_URL:-} networks: diff --git a/drizzle/0004_drop_mailbox_tokens_is_admin.sql b/drizzle/0004_drop_mailbox_tokens_is_admin.sql new file mode 100644 index 0000000..e10878b --- /dev/null +++ b/drizzle/0004_drop_mailbox_tokens_is_admin.sql @@ -0,0 +1,5 @@ +-- Migration: drop is_admin from mailbox_tokens +-- Admin status is now sourced exclusively from users.is_admin. +-- Apply manually: psql $DATABASE_URL -f drizzle/0004_drop_mailbox_tokens_is_admin.sql + +ALTER TABLE mailbox_tokens DROP COLUMN IF EXISTS is_admin; diff --git a/server/routes/api/admin/users/[id].patch.ts b/server/routes/api/admin/users/[id].patch.ts index 21847aa..954db3e 100644 --- a/server/routes/api/admin/users/[id].patch.ts +++ b/server/routes/api/admin/users/[id].patch.ts @@ -94,6 +94,11 @@ export default defineEventHandler(async (event) => { }); } + // Clear auth cache when admin status changes (isAdmin is now read from users table on each auth) + if ("isAdmin" in patch) { + clearAuthCache(); + } + // Sync in-memory mailbox set and token validity immediately if ("archivedAt" in patch) { if (patch.archivedAt) { diff --git a/server/routes/api/auth/register.post.ts b/server/routes/api/auth/register.post.ts index 14947ad..f9858c0 100644 --- a/server/routes/api/auth/register.post.ts +++ b/server/routes/api/auth/register.post.ts @@ -72,7 +72,6 @@ export default defineEventHandler(async (event) => { .values({ token, identity, - isAdmin: invite.isAdmin, label, createdBy: invite.createdBy, webhookToken: token, @@ -103,7 +102,7 @@ export default defineEventHandler(async (event) => { return { identity: tokenRow.identity, token: tokenRow.token, - isAdmin: tokenRow.isAdmin, + isAdmin: invite.isAdmin, message: `Welcome to Hive, ${identity}! Save your token — it won't be shown again. Use the same token for both API auth (Authorization: Bearer ) and gateway webhook config (hooks.token).`, }; }); diff --git a/server/routes/api/auth/tokens.get.ts b/server/routes/api/auth/tokens.get.ts index a76108c..137b0d6 100644 --- a/server/routes/api/auth/tokens.get.ts +++ b/server/routes/api/auth/tokens.get.ts @@ -1,7 +1,7 @@ -import { desc } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { defineEventHandler } from "h3"; import { db } from "@/db"; -import { mailboxTokens } from "@/db/schema"; +import { mailboxTokens, users } from "@/db/schema"; import { authenticateEvent } from "@/lib/auth"; export default defineEventHandler(async (event) => { @@ -17,7 +17,7 @@ export default defineEventHandler(async (event) => { .select({ id: mailboxTokens.id, identity: mailboxTokens.identity, - isAdmin: mailboxTokens.isAdmin, + isAdmin: users.isAdmin, // derived from users table label: mailboxTokens.label, createdBy: mailboxTokens.createdBy, createdAt: mailboxTokens.createdAt, @@ -26,6 +26,7 @@ export default defineEventHandler(async (event) => { webhookToken: mailboxTokens.webhookToken, }) .from(mailboxTokens) + .leftJoin(users, eq(mailboxTokens.identity, users.id)) .orderBy(desc(mailboxTokens.createdAt)) .limit(100); diff --git a/server/routes/api/auth/tokens/[id]/rotate.post.ts b/server/routes/api/auth/tokens/[id]/rotate.post.ts index 1883378..aea2895 100644 --- a/server/routes/api/auth/tokens/[id]/rotate.post.ts +++ b/server/routes/api/auth/tokens/[id]/rotate.post.ts @@ -72,13 +72,12 @@ export default defineEventHandler(async (event) => { .set({ revokedAt: new Date() }) .where(eq(mailboxTokens.id, Number(id))); - // Create new token with same identity and permissions + // Create new token with same identity const [newRow] = await db .insert(mailboxTokens) .values({ token: newToken, identity: existing.identity, - isAdmin: existing.isAdmin, label: `Rotated from token #${id}`, createdBy: auth.identity, webhookUrl: existing.webhookUrl, @@ -94,7 +93,6 @@ export default defineEventHandler(async (event) => { id: newRow.id, identity: newRow.identity, token: newRow.token, - isAdmin: newRow.isAdmin, }, message: "Token rotated. Old token is now revoked. Update your configuration with the new token.", diff --git a/server/routes/api/doctor.get.ts b/server/routes/api/doctor.get.ts index fac3a3b..9ed1350 100644 --- a/server/routes/api/doctor.get.ts +++ b/server/routes/api/doctor.get.ts @@ -63,9 +63,11 @@ export default defineEventHandler(async (event) => { summary: `Missing env vars: ${missing.join(", ")}`, }; } - // Check for deprecated vars - if (process.env.MAILBOX_TOKEN) { - warnings.push("MAILBOX_TOKEN is deprecated, use HIVE_TOKEN"); + // Check for required superuser config + if (!process.env.SUPERUSER_TOKEN || !process.env.SUPERUSER_NAME) { + warnings.push( + "SUPERUSER_TOKEN and SUPERUSER_NAME are required for admin access", + ); } if (warnings.length > 0) { return { status: "warn", summary: warnings.join("; ") }; diff --git a/server/routes/api/users.get.ts b/server/routes/api/users.get.ts index 905161e..687fc7e 100644 --- a/server/routes/api/users.get.ts +++ b/server/routes/api/users.get.ts @@ -1,11 +1,11 @@ import { defineEventHandler } from "h3"; -import { authenticateEvent, getEnvIdentities, listUsers } from "@/lib/auth"; +import { authenticateEvent, listUsers } from "@/lib/auth"; /** * GET /api/users * Returns all active users. Requires authentication (not admin-only). - * Includes users from the users table AND env-token identities that haven't - * been backfilled yet (returned as minimal user objects). + * All users, including the superuser, are guaranteed to have a row in the + * users table (auto-created at startup for the superuser). */ export default defineEventHandler(async (event) => { const auth = await authenticateEvent(event); @@ -17,22 +17,5 @@ export default defineEventHandler(async (event) => { } const dbUsers = await listUsers(); - const dbIds = new Set(dbUsers.map((u) => u.id)); - - // Include env-token identities not yet in the users table - const envOnly = getEnvIdentities() - .filter((id) => !dbIds.has(id)) - .map((id) => ({ - id, - displayName: id, - isAdmin: false, - isAgent: false, - avatarUrl: null, - createdAt: null, - updatedAt: null, - lastSeenAt: null, - archivedAt: null, - })); - - return { users: [...dbUsers, ...envOnly] }; + return { users: dbUsers }; }); diff --git a/src/db/schema.ts b/src/db/schema.ts index 453c4d4..3f3ed48 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -317,7 +317,6 @@ export const mailboxTokens = pgTable("mailbox_tokens", { id: bigserial("id", { mode: "number" }).primaryKey(), token: varchar("token", { length: 64 }).notNull().unique(), identity: varchar("identity", { length: 50 }).notNull(), - isAdmin: boolean("is_admin").notNull().default(false), label: varchar("label", { length: 100 }), createdBy: varchar("created_by", { length: 50 }), createdAt: timestamp("created_at", { withTimezone: true }) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ac7ce9d..845303d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,20 +1,16 @@ -import { config } from "dotenv"; import { and, eq, gt, isNull, or } from "drizzle-orm"; import type { H3Event } from "h3"; import { getHeader } from "h3"; import { db } from "@/db"; import { mailboxTokens, users } from "@/db/schema"; -config({ path: ".env" }); -config({ path: "/etc/clawdbot/vault.env" }); - export interface AuthContext { identity: string; isAdmin: boolean; source: "db" | "env"; } -// In-memory env token cache (loaded once at startup) +// In-memory env token cache (loaded once at startup — superuser only) const envTokens = new Map(); const validMailboxes = new Set(); @@ -40,6 +36,31 @@ async function loadUsersFromDb() { } } +/** + * Ensure the superuser's users row exists and is marked as admin. + * Called at startup when SUPERUSER_NAME is configured. + */ +async function ensureSuperuser(name: string, displayName: string) { + try { + await db + .insert(users) + .values({ + id: name, + displayName, + isAdmin: true, + isAgent: false, + }) + .onConflictDoUpdate({ + target: users.id, + set: { isAdmin: true, updatedAt: new Date() }, + }); + validMailboxes.add(name); + console.log(`[auth] Superuser "${name}" ensured in DB`); + } catch (err) { + console.error("[auth] Failed to ensure superuser in DB:", err); + } +} + /** Return all non-archived users ordered by display name */ export async function listUsers() { return db @@ -62,16 +83,7 @@ export function deregisterMailbox(name: string) { validMailboxes.delete(name); } -/** Return all identities registered via env tokens (HIVE_TOKEN_* / MAILBOX_TOKEN_* / UI_MAILBOX_KEYS) */ -export function getEnvIdentities(): string[] { - const ids = new Set(); - for (const ctx of envTokens.values()) { - if (ctx.identity && ctx.identity !== "admin") ids.add(ctx.identity); - } - return [...ids].sort(); -} - -/** Check DB for a valid token */ +/** Check DB for a valid token — isAdmin is derived from users table */ async function authenticateFromDb(token: string): Promise { // Check cache first const cached = dbCache.get(token); @@ -81,12 +93,19 @@ async function authenticateFromDb(token: string): Promise { try { const [row] = await db - .select() + .select({ + id: mailboxTokens.id, + identity: mailboxTokens.identity, + expiresAt: mailboxTokens.expiresAt, + isAdmin: users.isAdmin, // derived from users table, not mailboxTokens + }) .from(mailboxTokens) + .innerJoin(users, eq(mailboxTokens.identity, users.id)) .where( and( eq(mailboxTokens.token, token), isNull(mailboxTokens.revokedAt), + isNull(users.archivedAt), or( isNull(mailboxTokens.expiresAt), gt(mailboxTokens.expiresAt, new Date()), @@ -122,24 +141,24 @@ async function authenticateFromDb(token: string): Promise { } } -/** Authenticate a token — checks DB first, then env vars */ +/** Authenticate a token — checks env (superuser) first, then DB */ export async function authenticateTokenAsync( token: string, ): Promise { - // DB tokens take priority - const dbAuth = await authenticateFromDb(token); - if (dbAuth) return dbAuth; + // Superuser env token takes priority + const envAuth = envTokens.get(token); + if (envAuth) return envAuth; - // Fall back to env tokens - return envTokens.get(token) || null; + // Fall back to DB tokens + return authenticateFromDb(token); } -/** Sync version — only checks env tokens (for backwards compat) */ +/** Sync version — only checks env tokens (superuser) */ export function authenticateToken(token: string): AuthContext | null { return envTokens.get(token) || null; } -/** Authenticate from H3 event — async, checks DB + env */ +/** Authenticate from H3 event — async, checks env + DB */ export async function authenticateEvent( event: H3Event, ): Promise { @@ -151,81 +170,34 @@ export async function authenticateEvent( export function initAuth() { envTokens.clear(); - // Support both HIVE_TOKEN_* (preferred) and MAILBOX_TOKEN_* (backward compat) - for (const [key, value] of Object.entries(process.env)) { - const prefixes = ["HIVE_TOKEN_", "MAILBOX_TOKEN_"]; - for (const prefix of prefixes) { - if (key.startsWith(prefix) && !key.endsWith("_ADMIN") && value) { - const name = key.slice(prefix.length).toLowerCase(); - if (name && name !== "s") { - envTokens.set(value, { - identity: name, - isAdmin: false, - source: "env", - }); - validMailboxes.add(name); - } - } - } - } - - // Support both HIVE_TOKENS and MAILBOX_TOKENS (JSON maps) - const tokensEnv = process.env.HIVE_TOKENS || process.env.MAILBOX_TOKENS; - if (tokensEnv) { - try { - const mapping = JSON.parse(tokensEnv) as Record; - for (const [token, identity] of Object.entries(mapping)) { - envTokens.set(token, { - identity: identity.toLowerCase(), - isAdmin: false, - source: "env", - }); - validMailboxes.add(identity.toLowerCase()); - } - } catch (err) { - console.error("[auth] Failed to parse HIVE_TOKENS/MAILBOX_TOKENS:", err); - } - } - - if (process.env.UI_MAILBOX_KEYS) { - try { - const parsed = JSON.parse(process.env.UI_MAILBOX_KEYS) as Record< - string, - { sender: string; admin?: boolean } - >; - for (const [key, info] of Object.entries(parsed)) { - envTokens.set(key, { - identity: info.sender.toLowerCase(), - isAdmin: info.admin || false, - source: "env", - }); - validMailboxes.add(info.sender.toLowerCase()); - } - console.log( - `[auth] Loaded ${Object.keys(parsed).length} UI mailbox key(s)`, - ); - } catch (err) { - console.error("[auth] Failed to parse UI_MAILBOX_KEYS:", err); - } - } + const superuserToken = process.env.SUPERUSER_TOKEN; + const superuserName = process.env.SUPERUSER_NAME?.toLowerCase().trim(); - // Fallback: HIVE_TOKEN or MAILBOX_TOKEN (single token, identity from USER) - const singleToken = process.env.HIVE_TOKEN || process.env.MAILBOX_TOKEN; - if (singleToken && envTokens.size === 0) { - const identity = process.env.USER?.toLowerCase() || "unknown"; - envTokens.set(singleToken, { identity, isAdmin: false, source: "env" }); - validMailboxes.add(identity); - } + if (superuserToken && superuserName) { + const rawDisplayName = process.env.SUPERUSER_DISPLAY_NAME?.trim(); + // Default display name: title-case the name slug (e.g. "chris" → "Chris") + const displayName = + rawDisplayName || + superuserName.charAt(0).toUpperCase() + superuserName.slice(1); - if (process.env.MAILBOX_ADMIN_TOKEN) { - envTokens.set(process.env.MAILBOX_ADMIN_TOKEN, { - identity: "admin", + envTokens.set(superuserToken, { + identity: superuserName, isAdmin: true, source: "env", }); - } + validMailboxes.add(superuserName); + + console.log(`[auth] Superuser token loaded for "${superuserName}"`); - console.log(`[auth] Loaded ${envTokens.size} env token(s), DB auth enabled`); + // Ensure superuser exists in DB (fire-and-forget) + ensureSuperuser(superuserName, displayName).catch((err) => + console.error("[auth] ensureSuperuser failed:", err), + ); + } else { + console.warn( + "[auth] SUPERUSER_TOKEN and/or SUPERUSER_NAME not set — no env-based admin access", + ); + } // Load known users from DB into validMailboxes (fire-and-forget) loadUsersFromDb().catch((err) => @@ -233,7 +205,7 @@ export function initAuth() { ); } -/** Clear the DB token cache (e.g., after creating/revoking tokens) */ +/** Clear the DB token cache (e.g., after creating/revoking tokens or changing user admin status) */ export function clearAuthCache() { dbCache.clear(); } From a3c60c52d26eb2d1f52257cff076703e5e9aed18 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 08:01:14 -0600 Subject: [PATCH 02/41] =?UTF-8?q?chore:=20rename=20dev=20service=20hive=20?= =?UTF-8?q?=E2=86=92=20hivetest=20in=20docker-compose.dev.yml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index bb9f0b2..5eb90a7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -21,7 +21,7 @@ services: timeout: 3s retries: 5 - hive: + hivetest: build: . restart: unless-stopped ports: From 8f71e6b5aad7def4e184f4a1d5d7511dbca3bf94 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 08:04:12 -0600 Subject: [PATCH 03/41] chore: revert dev service name to hive, add docker-compose.test.yml (no DB) --- docker-compose.dev.yml | 2 +- docker-compose.test.yml | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 docker-compose.test.yml diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5eb90a7..bb9f0b2 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -21,7 +21,7 @@ services: timeout: 3s retries: 5 - hivetest: + hive: build: . restart: unless-stopped ports: diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..88b663e --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,31 @@ +# Test stack — runs Hive against an existing database (no DB container). +# Use when you want to test against a real/shared DB without spinning up postgres. +# +# Usage: docker compose -f docker-compose.test.yml up +# Requires: PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE_TEAM set in your env or a .env file. + +services: + hivetest: + build: . + restart: unless-stopped + ports: + - "3000:3000" + environment: + - NODE_ENV=development + - PORT=3000 + - HOST=0.0.0.0 + - PGHOST=${PGHOST} + - PGPORT=${PGPORT:-5432} + - PGUSER=${PGUSER} + - PGPASSWORD=${PGPASSWORD} + - PGDATABASE_TEAM=${PGDATABASE_TEAM} + - HIVE_BASE_URL=${HIVE_BASE_URL:-http://localhost:3000} + - SUPERUSER_TOKEN=${SUPERUSER_TOKEN:-dev-superuser-token} + - SUPERUSER_NAME=${SUPERUSER_NAME:-admin} + - SUPERUSER_DISPLAY_NAME=${SUPERUSER_DISPLAY_NAME:-Admin} + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 15s From b760e32fd0d2ec7518e43325682f5afc505ad11e Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 08:27:20 -0600 Subject: [PATCH 04/41] feat: auto-migrate on startup + first-run setup UI + fix docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/plugins/startup.ts: Nitro plugin that runs drizzle SQL migrations automatically on every server start (idempotent, tracked in _hive_migrations) - Dockerfile: copy drizzle/ into production image so migrations are available - server/routes/api/auth/setup-profile.post.ts: endpoint to set own display name - src/components/setup-profile.tsx: first-run welcome screen asking for display name - src/routes/index.tsx: detect first-run (admin + ≤1 user) and show setup before inbox - src/components/login-gate.tsx: rename 'mailbox key' → 'Hive key', improve error msg - docs/quickstart.md: replace MAILBOX_ADMIN_TOKEN with SUPERUSER_TOKEN model, add first-run flow description, add docker-compose.test.yml option - docs/configuration.md: rewrite auth section for SUPERUSER_TOKEN model, remove stale HIVE_TOKEN_*/MAILBOX_TOKEN_* references --- Dockerfile | 1 + .../docs/getting-started/configuration.md | 132 +++++++----------- .../docs/getting-started/quickstart.md | 104 +++++++------- server/plugins/startup.ts | 90 ++++++++++++ server/routes/api/auth/setup-profile.post.ts | 47 +++++++ src/components/login-gate.tsx | 6 +- src/components/setup-profile.tsx | 106 ++++++++++++++ src/routes/index.tsx | 101 ++++++++++++-- 8 files changed, 448 insertions(+), 139 deletions(-) create mode 100644 server/plugins/startup.ts create mode 100644 server/routes/api/auth/setup-profile.post.ts create mode 100644 src/components/setup-profile.tsx diff --git a/Dockerfile b/Dockerfile index f85ff9e..1f0400e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,7 @@ ENV NODE_ENV=production COPY --from=build /app/.output ./.output COPY --from=build /app/SKILL.md ./SKILL.md COPY --from=build /app/scripts ./scripts +COPY --from=build /app/drizzle ./drizzle EXPOSE 3000 CMD ["bun", "run", ".output/server/index.mjs"] # force rebuild 1771371912 diff --git a/docs/src/content/docs/getting-started/configuration.md b/docs/src/content/docs/getting-started/configuration.md index d7c07f6..baeeba3 100644 --- a/docs/src/content/docs/getting-started/configuration.md +++ b/docs/src/content/docs/getting-started/configuration.md @@ -14,7 +14,7 @@ This page explains what each variable does and when you'd want to configure it. At minimum, you need: 1. A PostgreSQL database -2. An admin token +2. A superuser token ```bash # PostgreSQL connection @@ -24,14 +24,16 @@ export PGUSER=hive export PGPASSWORD=your-password export PGDATABASE_TEAM=hive -# Admin token (for managing the instance) -export MAILBOX_ADMIN_TOKEN=your-admin-token +# Superuser — controls who has admin access to the instance +export SUPERUSER_TOKEN=your-long-random-secret +export SUPERUSER_NAME=chris # your identity slug +export SUPERUSER_DISPLAY_NAME=Chris # optional, defaults to title-case of SUPERUSER_NAME # Start Hive -npm start +bun start ``` -That's it. Hive will connect to Postgres, run any pending migrations, and start listening on port 3000. +Hive will connect to Postgres, run any pending migrations automatically, create the superuser record, and start listening on port 3000. ## Database Configuration @@ -60,7 +62,9 @@ PGPORT=5432 PGUSER=hive_dev PGPASSWORD=dev-password PGDATABASE_TEAM=hive_dev -MAILBOX_ADMIN_TOKEN=dev-admin-token +SUPERUSER_TOKEN=dev-superuser-token +SUPERUSER_NAME=admin +SUPERUSER_DISPLAY_NAME=Admin ``` ### Example: Production with Connection String @@ -126,95 +130,63 @@ Set to `production` for: export NODE_ENV=production ``` -## Authentication Tokens +## Authentication -Hive uses **bearer tokens** for authentication. Most REST endpoints require: +Hive uses **bearer tokens** for all API access: ```http Authorization: Bearer ``` -There are two ways to configure tokens: +### Superuser (env-configured) -### 1. Environment Variables (Static Tokens) +The superuser is defined via environment variables and has full admin access: -Define tokens for agents and users directly in your environment: +| Variable | Description | Required | +|----------|-------------|----------| +| `SUPERUSER_TOKEN` | The superuser's API token | **Yes** | +| `SUPERUSER_NAME` | Identity slug (e.g. `chris`) | **Yes** | +| `SUPERUSER_DISPLAY_NAME` | Display name in the UI | No (defaults to title-case of name) | -```bash -# Preferred format -export HIVE_TOKEN_CLIO=clio-secret-token -export HIVE_TOKEN_OPS=ops-secret-token -``` - -**The `` suffix (lowercased) becomes the identity.** - -`HIVE_TOKEN_CLIO=abc123` creates the identity `clio`. - -**Legacy format (still works):** +On startup, Hive automatically creates (or updates) the superuser's record in the `users` table. The superuser can then log into the web UI using their `SUPERUSER_TOKEN` and manage everything from the Admin panel. -```bash -export MAILBOX_TOKEN_CLIO=clio-secret-token -``` - -**Why static tokens?** +**Keep `SUPERUSER_TOKEN` secret.** Anyone with this token has full admin access. -- Simple setup — no database lookup needed -- Fast authentication — no DB query per request -- Good for agents, services, and service accounts +### All Other Users — Database Tokens -**When to use:** Internal agents, CI/CD pipelines, service-to-service authentication. +Every other user (teammates, agents) authenticates via a DB-issued token: -### 2. Database Tokens (Dynamic) +1. The superuser creates an invite: `POST /api/auth/invites` +2. The invitee visits `/onboard?code=` and registers +3. They receive a personal token — this is their `HIVE_TOKEN` +4. They store it in their environment and use it for all API calls -Tokens can also be created via the registration flow (invite → register). These are stored in the database and support: +DB tokens support: +- **Expiration** — set tokens to expire after a period +- **Revocation** — revoke without redeploying +- **Rotation** — roll tokens via `POST /api/auth/tokens/:id/rotate` -- **Expiration:** Set tokens to expire after a period -- **Revocation:** Revoke tokens without redeploying -- **User attribution:** Track who owns each token +**Admin status** is set on the `users` table — not on the token. Granting or revoking admin access for any user is done via the Admin panel (`/admin`). -**When to use:** Human users, temporary access, external integrations. +### First Run -### Admin Token +When you open Hive for the first time with a fresh database, you'll be prompted to: -The `MAILBOX_ADMIN_TOKEN` is special — it has full access to all endpoints and can: +1. Enter your `SUPERUSER_TOKEN` as the Hive key +2. Set your display name -- Create and manage identities -- Generate invites -- Manage webhooks -- View system health - -```bash -export MAILBOX_ADMIN_TOKEN=your-secure-admin-token -``` - -**Keep this secure.** In production, use a strong random token and rotate it periodically. +After that, your account is fully set up and you can invite others. ## Agent Webhooks -When you want external agents to receive notifications (e.g., chat messages), configure webhooks: +Webhook URLs are stored per-token in the database. When a user registers via the invite flow, a webhook URL can be configured as part of their token. Agents update their webhook URL via: ```bash -export WEBHOOK_CLIO_URL=http://your-agent-server:18789/hooks/agent -export WEBHOOK_CLIO_TOKEN=webhook-auth-token +POST /api/auth/webhook +{ "url": "https://your-agent-host/hooks/agent", "token": "webhook-auth-token" } ``` -When Hive receives a message for the `clio` identity, it POSTs to the webhook URL. - -**Why use webhooks?** - -- Your agent doesn't need to poll Hive -- Real-time notifications -- Works with any HTTP-capable agent runtime - -**Webhook payload format:** - -```json -{ - "event": "message", - "identity": "clio", - "data": { ... } -} -``` +See the [onboarding guide](/admin/onboarding/) for the full agent setup flow. ## External Services @@ -242,19 +214,17 @@ PORT=3000 HOST=0.0.0.0 NODE_ENV=production -# Authentication -MAILBOX_ADMIN_TOKEN=secure-admin-token-here -HIVE_TOKEN_CLIO=clio-agent-token -HIVE_TOKEN_OPS=ops-agent-token - -# Webhooks (optional) -WEBHOOK_CLIO_URL=http://clio-agent:18789/hooks/agent -WEBHOOK_CLIO_TOKEN=webhook-secret +# Superuser +SUPERUSER_TOKEN=secure-superuser-token-here +SUPERUSER_NAME=chris +SUPERUSER_DISPLAY_NAME=Chris # External services (optional) ONEDEV_URL=https://dev.yourcompany.com ``` +All other users and agents get their tokens through the invite/register flow — no additional env vars needed. + ## Troubleshooting ### Database connection fails @@ -264,11 +234,11 @@ ONEDEV_URL=https://dev.yourcompany.com - Ensure the user has permissions on the database - Check firewall rules if connecting to a remote database -### Tokens not recognized +### Can't log in / token not recognized -- Ensure you're using the correct variable format: `HIVE_TOKEN_` or `MAILBOX_TOKEN_` -- Restart Hive after adding new tokens — static tokens are loaded at startup -- Check for typos in the identity name (lowercased) +- Verify `SUPERUSER_TOKEN` in your `.env` exactly matches what you're entering in the UI +- Restart Hive after changing `SUPERUSER_TOKEN` — the env value is read at startup +- For other users, check that their token hasn't been revoked (Admin → Tokens) ### Links point to localhost diff --git a/docs/src/content/docs/getting-started/quickstart.md b/docs/src/content/docs/getting-started/quickstart.md index 93342bf..6f65116 100644 --- a/docs/src/content/docs/getting-started/quickstart.md +++ b/docs/src/content/docs/getting-started/quickstart.md @@ -9,7 +9,7 @@ Ready to try Hive? You'll be up and running in just a few minutes. ## Option 1: Docker Compose (Fastest) -The quickest way to get started — everything in one command: +The quickest way to get started — Hive, PostgreSQL, and automatic migrations, all in one command: ```bash # Clone the repo @@ -19,23 +19,38 @@ cd hive # Copy the example environment cp .env.example .env -# Edit .env and set at least MAILBOX_ADMIN_TOKEN -# (use a secure random string for the admin token) +# Edit .env — set your superuser credentials: +# SUPERUSER_TOKEN= +# SUPERUSER_NAME= +# +# Optional but recommended: +# SUPERUSER_DISPLAY_NAME=Chris -# Start Hive +# Start Hive (includes PostgreSQL) docker compose -f docker-compose.dev.yml up ``` -That's it. Hive will be available at `http://localhost:3000`. +Hive will be available at `http://localhost:3000`. **What you get:** - Hive application running on port 3000 - PostgreSQL database (in a container) -- All migrations applied automatically +- DB schema created and migrations applied automatically on startup + +## Option 2: Against an Existing Database + +If you already have a PostgreSQL instance and just want to run the Hive container: -## Option 2: From Source +```bash +cp .env.example .env +# Set your DB credentials and SUPERUSER_TOKEN/SUPERUSER_NAME -If you prefer to run directly with Node.js or Bun: +docker compose -f docker-compose.test.yml up +``` + +## Option 3: From Source + +If you prefer to run directly with Bun: ### Prerequisites @@ -52,10 +67,9 @@ cd hive # Copy the example environment cp .env.example .env -# Edit .env with your database credentials and tokens -# At minimum, set: -# - PGHOST, PGUSER, PGPASSWORD, PGDATABASE_TEAM -# - MAILBOX_ADMIN_TOKEN +# Edit .env — at minimum set: +# PGHOST, PGUSER, PGPASSWORD, PGDATABASE_TEAM +# SUPERUSER_TOKEN, SUPERUSER_NAME # Install dependencies bun install @@ -69,46 +83,44 @@ bun run dev Hive will be available at `http://localhost:3000`. -## First Steps +## First Run + +When you open Hive in your browser for the first time: + +1. **Enter your Hive key** — this is the value of `SUPERUSER_TOKEN` from your `.env` +2. **Set your display name** — you'll be prompted to enter a display name before reaching the main interface +3. **You're in** — start inviting teammates via the Admin panel (`/admin → Invites`) -Now that Hive is running, let's make sure everything works. +## First Steps ### 1. Verify Your Token -Test that your admin token is working: +Test that your key is working: ```bash curl -X POST http://localhost:3000/api/auth/verify \ - -H "Authorization: Bearer YOUR_ADMIN_TOKEN" + -H "Authorization: Bearer YOUR_SUPERUSER_TOKEN" ``` -You should get back a JSON response with your token info. - -### 2. Check Wake - -Call the Wake endpoint to see your action queue: - -```bash -curl http://localhost:3000/api/wake \ - -H "Authorization: Bearer YOUR_ADMIN_TOKEN" +You should get back: +```json +{ "identity": "chris", "isAdmin": true } ``` -If this is a fresh install, you'll get an empty queue — that's expected. - -### 3. Create an Invite +### 2. Invite a Teammate -If you want to let others register: +Create an invite link to share with a teammate or agent: ```bash curl -X POST http://localhost:3000/api/auth/invites \ - -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \ + -H "Authorization: Bearer YOUR_SUPERUSER_TOKEN" \ -H "Content-Type: application/json" \ - -d '{"maxUses": 5}' + -d '{"maxUses": 1}' ``` -This creates an invite link you can share with teammates. +Share the returned `code` — they'll visit `/onboard?code=` to register. -### 4. Explore the Web UI +### 3. Explore the Web UI Open `http://localhost:3000` in your browser. You'll see: @@ -117,9 +129,9 @@ Open `http://localhost:3000` in your browser. You'll see: - **Notebook** — Collaborative documents - **Buzz** — Event broadcasts - **Directory** — Shared links -- **Admin** — Configuration (at `/admin`) +- **Admin** — User and token management (at `/admin`) -### 5. Read the Skill Docs +### 4. Read the Skill Docs Hive provides machine-readable documentation for agents: @@ -127,14 +139,12 @@ Hive provides machine-readable documentation for agents: curl http://localhost:3000/api/skill ``` -This returns documentation that helps agents understand how to use Hive's APIs. - ## Next Steps -- **[Configuration](/getting-started/configuration/)** — Understand all the environment variables -- **[Wake API](/features/wake/)** — Learn how agents get their action queue -- **[Messaging](/features/messaging/)** — Set up inbox-based communication -- **[Swarm](/features/swarm/)** — Start tracking tasks +- **[Configuration](/getting-started/configuration/)** — All environment variables explained +- **[Wake API](/features/wake/)** — How agents receive their action queue +- **[Messaging](/features/messaging/)** — Inbox-based communication +- **[Swarm](/features/swarm/)** — Task tracking ## Troubleshooting @@ -158,16 +168,16 @@ PGPASSWORD=your_password PGDATABASE_TEAM=hive ``` -Make sure PostgreSQL is running and the database exists. - -### Migrations fail - -Ensure your database user has permissions to create tables: +Make sure PostgreSQL is running and the database exists. Ensure your user has permission to create tables: ```sql GRANT ALL ON DATABASE hive TO your_user; ``` +### Can't log in + +Make sure `SUPERUSER_TOKEN` in your `.env` matches exactly what you're entering in the UI. There are no default credentials — you set the token. + --- -Welcome to Hive! 🐝 \ No newline at end of file +Welcome to Hive! 🐝 diff --git a/server/plugins/startup.ts b/server/plugins/startup.ts new file mode 100644 index 0000000..5d5600c --- /dev/null +++ b/server/plugins/startup.ts @@ -0,0 +1,90 @@ +/** + * Startup plugin — runs once when the Nitro server initialises. + * + * Responsibilities: + * 1. Apply any pending DB migrations (idempotent — safe on every restart) + * 2. Ensure the superuser record exists in the users table + * + * Migrations are read from ./drizzle relative to the working directory. + * In production (Docker) the drizzle/ folder is copied into the image. + * In development Vite/Nitro serves from the repo root where it already exists. + */ + +import { sql } from "drizzle-orm"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { db } from "@/db"; + +async function runMigrations() { + const migrationsDir = join(process.cwd(), "drizzle"); + + // Ensure the migrations tracking table exists + await db.execute(sql` + CREATE TABLE IF NOT EXISTS _hive_migrations ( + id serial PRIMARY KEY, + filename text NOT NULL UNIQUE, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `); + + // Read all .sql files (sorted — numeric prefix guarantees order) + let files: string[]; + try { + const entries = await readdir(migrationsDir); + files = entries + .filter((f) => f.endsWith(".sql") && !f.startsWith("_")) + .sort(); + } catch { + console.warn( + `[migrate] drizzle/ folder not found at ${migrationsDir} — skipping migrations`, + ); + return; + } + + // Fetch already-applied migrations + const applied = await db.execute<{ filename: string }>( + sql`SELECT filename FROM _hive_migrations`, + ); + const appliedSet = new Set(applied.map((r) => r.filename)); + + let ran = 0; + for (const file of files) { + if (appliedSet.has(file)) continue; + + const filePath = join(migrationsDir, file); + const raw = await readFile(filePath, "utf8"); + + // Strip comments and split on semicolons so we can run multi-statement files + const statements = raw + .split(";") + .map((s) => s.replace(/--[^\n]*/g, "").trim()) + .filter(Boolean); + + for (const stmt of statements) { + await db.execute(sql.raw(stmt)); + } + + await db.execute( + sql`INSERT INTO _hive_migrations (filename) VALUES (${file})`, + ); + + console.log(`[migrate] Applied: ${file}`); + ran++; + } + + if (ran === 0) { + console.log(`[migrate] DB up to date (${files.length} migration(s) already applied)`); + } else { + console.log(`[migrate] Applied ${ran} migration(s)`); + } +} + +export default defineNitroPlugin(async () => { + try { + await runMigrations(); + } catch (err) { + console.error("[migrate] Migration failed — server may be unstable:", err); + // Don't crash the server — let it start and surface DB errors naturally. + // A broken schema is easier to debug than a server that won't start. + } +}); diff --git a/server/routes/api/auth/setup-profile.post.ts b/server/routes/api/auth/setup-profile.post.ts new file mode 100644 index 0000000..434a720 --- /dev/null +++ b/server/routes/api/auth/setup-profile.post.ts @@ -0,0 +1,47 @@ +import { eq } from "drizzle-orm"; +import { defineEventHandler, readBody } from "h3"; +import { db } from "@/db"; +import { users } from "@/db/schema"; +import { authenticateEvent } from "@/lib/auth"; + +/** + * POST /api/auth/setup-profile + * + * First-run display name setup. Allows the authenticated user to set their + * own display name. No admin required — any authenticated user can call this + * to set their own name (identity comes from the auth token, not the body). + */ +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const body = await readBody(event); + const displayName = body?.displayName?.trim(); + + if (!displayName || displayName.length < 1 || displayName.length > 100) { + return new Response( + JSON.stringify({ error: "displayName must be 1–100 characters" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const [updated] = await db + .update(users) + .set({ displayName, updatedAt: new Date() }) + .where(eq(users.id, auth.identity)) + .returning(); + + if (!updated) { + return new Response(JSON.stringify({ error: "User not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + return { identity: updated.id, displayName: updated.displayName }; +}); diff --git a/src/components/login-gate.tsx b/src/components/login-gate.tsx index e6c87e6..0d4d46d 100644 --- a/src/components/login-gate.tsx +++ b/src/components/login-gate.tsx @@ -31,7 +31,7 @@ export function LoginGate({ onLogin }: { onLogin: () => void }) { } onLogin(); } catch { - setError("Invalid mailbox key"); + setError("Invalid key — check your SUPERUSER_TOKEN or personal token"); } finally { setLoading(false); } @@ -54,14 +54,14 @@ export function LoginGate({ onLogin }: { onLogin: () => void }) { />

- Enter your mailbox key to continue + Enter your Hive key to continue

setKey(e.target.value)} autoFocus diff --git a/src/components/setup-profile.tsx b/src/components/setup-profile.tsx new file mode 100644 index 0000000..469ec9c --- /dev/null +++ b/src/components/setup-profile.tsx @@ -0,0 +1,106 @@ +import { Sparkles } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { getMailboxKey } from "@/lib/api"; + +interface SetupProfileProps { + currentDisplayName: string; + onComplete: () => void; +} + +/** + * First-run setup screen — shown when the superuser logs in for the first time + * and hasn't set a proper display name yet (or when there are no other users, + * indicating a fresh install). + */ +export function SetupProfile({ + currentDisplayName, + onComplete, +}: SetupProfileProps) { + const [name, setName] = useState(currentDisplayName); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + + setSubmitting(true); + setError(""); + + try { + const key = getMailboxKey(); + const res = await fetch("/api/auth/setup-profile", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${key}`, + }, + body: JSON.stringify({ displayName: name.trim() }), + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error || "Failed to save display name"); + return; + } + + // Mark setup as done so we don't show this again + localStorage.setItem("hive-setup-complete", "1"); + onComplete(); + } catch { + setError("Network error — try again"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ + +
+ +

Welcome to Hive!

+
+ +

+ Your instance is ready. Before you dive in, what should we call you? +

+ + +
+ + setName(e.target.value)} + placeholder="e.g. Chris" + required + autoFocus + maxLength={100} + /> +
+ + {error && ( +
+ {error} +
+ )} + + + +
+
+
+ ); +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 24bb508..50c52a4 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -2,26 +2,111 @@ import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { InboxView } from "@/components/inbox"; import { LoginGate } from "@/components/login-gate"; +import { SetupProfile } from "@/components/setup-profile"; import { getMailboxKey } from "@/lib/api"; export const Route = createFileRoute("/")({ component: Home, }); +type AppState = "loading" | "unauthenticated" | "setup" | "ready"; + +interface UserInfo { + displayName: string; + isAdmin: boolean; +} + +async function checkFirstRun(key: string): Promise<{ + needsSetup: boolean; + user: UserInfo | null; +}> { + // Skip if the user has already completed setup this session + if (localStorage.getItem("hive-setup-complete")) { + return { needsSetup: false, user: null }; + } + + try { + const [verifyRes, usersRes] = await Promise.all([ + fetch("/api/auth/verify", { + method: "POST", + headers: { Authorization: `Bearer ${key}` }, + }), + fetch("/api/users", { + headers: { Authorization: `Bearer ${key}` }, + }), + ]); + + if (!verifyRes.ok || !usersRes.ok) return { needsSetup: false, user: null }; + + const { identity, isAdmin } = await verifyRes.json(); + const { users } = await usersRes.json(); + + // Show setup when: user is admin AND there's only one user (fresh install) + if (isAdmin && Array.isArray(users) && users.length <= 1) { + const me = users.find((u: { id: string }) => u.id === identity); + return { + needsSetup: true, + user: { + displayName: me?.displayName ?? identity, + isAdmin, + }, + }; + } + } catch { + // Silently fall through — don't block the app if this check fails + } + + return { needsSetup: false, user: null }; +} + function Home() { - const [authed, setAuthed] = useState(false); - const [checked, setChecked] = useState(false); + const [state, setState] = useState("loading"); + const [userInfo, setUserInfo] = useState(null); useEffect(() => { - setAuthed(!!getMailboxKey()); - setChecked(true); + const key = getMailboxKey(); + if (!key) { + setState("unauthenticated"); + return; + } + + checkFirstRun(key).then(({ needsSetup, user }) => { + if (needsSetup) { + setUserInfo(user); + setState("setup"); + } else { + setState("ready"); + } + }); }, []); - if (!checked) return null; + const handleLogin = () => { + const key = getMailboxKey(); + if (!key) return; + checkFirstRun(key).then(({ needsSetup, user }) => { + if (needsSetup) { + setUserInfo(user); + setState("setup"); + } else { + setState("ready"); + } + }); + }; + + if (state === "loading") return null; + + if (state === "unauthenticated") { + return ; + } - if (!authed) { - return setAuthed(true)} />; + if (state === "setup" && userInfo) { + return ( + setState("ready")} + /> + ); } - return setAuthed(false)} />; + return setState("unauthenticated")} />; } From 5d1792515b1f3efade352e3fcc3044f1f84a217c Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 08:33:54 -0600 Subject: [PATCH 05/41] fix: task detail dialog scrolls content, pins status buttons at bottom DialogContent gets max-h-[85vh] flex flex-col. View mode splits into a flex-1 overflow-y-auto body (detail text, linked pages, etc.) and a shrink-0 pinned footer (status buttons + edit). Edit form gets same overflow-y-auto treatment. Status actions are now always visible regardless of task detail length. --- src/routes/swarm.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 4edc17c..07424f7 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -1076,7 +1076,7 @@ function TaskDetailDialog({ return ( !open && onClose()}> e.stopPropagation()} > @@ -1105,7 +1105,7 @@ function TaskDetailDialog({ {editing ? ( -
+
setTitle(e.target.value)} @@ -1225,7 +1225,9 @@ function TaskDetailDialog({
) : ( -
+
+ {/* Scrollable content */} +
{/* Meta */}
{project && ( @@ -1388,6 +1390,10 @@ function TaskDetailDialog({
)} +
{/* end scrollable */} + + {/* Pinned footer — status buttons + edit always visible */} +
{/* Status actions */}
{ALL_STATUSES.filter((s) => s !== task.status).map((s) => { @@ -1489,6 +1495,7 @@ function TaskDetailDialog({ Edit
+
{/* end pinned footer */}
)} From 909bf440fcf218673af7be4171a9c7652df82dd9 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 08:35:01 -0600 Subject: [PATCH 06/41] fix: widen task detail and compose dialogs to 90vw --- src/components/compose-dialog.tsx | 2 +- src/routes/swarm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/compose-dialog.tsx b/src/components/compose-dialog.tsx index 5c94863..631b2a5 100644 --- a/src/components/compose-dialog.tsx +++ b/src/components/compose-dialog.tsx @@ -62,7 +62,7 @@ export function ComposeDialog({ return ( - + New Message diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 07424f7..c0837a5 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -1076,7 +1076,7 @@ function TaskDetailDialog({ return ( !open && onClose()}> e.stopPropagation()} > From 781ce97db2e1c13b42058d8c8e9f03e52f949f80 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 08:38:40 -0600 Subject: [PATCH 07/41] fix: remove drizzle from .dockerignore so migrations are available in Docker build --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 5eec5a5..58ecdfc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,6 @@ node_modules .output .git -drizzle *.md !SKILL.md .env From 720dd68b29c800121462a8c055ee19b4f7c8d467 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 08:52:52 -0600 Subject: [PATCH 08/41] fix: rewrite startup migration plugin to use postgres client directly Replace drizzle sql tagged templates (potential Nitro bundling issue) with raw postgres.js client calls. Uses sql.unsafe() for executing migration SQL statements. Creates its own short-lived connection that closes after migrations run. Simpler and avoids any ORM compatibility issues at startup. --- server/plugins/startup.ts | 128 ++++++++++++++++++++------------------ 1 file changed, 68 insertions(+), 60 deletions(-) diff --git a/server/plugins/startup.ts b/server/plugins/startup.ts index 5d5600c..1892a3c 100644 --- a/server/plugins/startup.ts +++ b/server/plugins/startup.ts @@ -1,81 +1,88 @@ /** * Startup plugin — runs once when the Nitro server initialises. * - * Responsibilities: - * 1. Apply any pending DB migrations (idempotent — safe on every restart) - * 2. Ensure the superuser record exists in the users table + * Applies any pending SQL migrations from the drizzle/ folder. + * Migrations are tracked in _hive_migrations (created here if absent). * - * Migrations are read from ./drizzle relative to the working directory. - * In production (Docker) the drizzle/ folder is copied into the image. - * In development Vite/Nitro serves from the repo root where it already exists. + * Uses the raw postgres client to avoid any ORM quirks at startup. */ -import { sql } from "drizzle-orm"; import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; -import { db } from "@/db"; +import postgres from "postgres"; async function runMigrations() { - const migrationsDir = join(process.cwd(), "drizzle"); + // Re-use the same connection config as src/db/index.ts + const host = process.env.HIVE_PGHOST || process.env.PGHOST || "localhost"; + const port = Number(process.env.PGPORT || 5432); + const user = process.env.PGUSER || "postgres"; + const password = process.env.PGPASSWORD || ""; + const database = + process.env.PGDATABASE_TEAM || process.env.PGDATABASE || "postgres"; - // Ensure the migrations tracking table exists - await db.execute(sql` - CREATE TABLE IF NOT EXISTS _hive_migrations ( - id serial PRIMARY KEY, - filename text NOT NULL UNIQUE, - applied_at timestamptz NOT NULL DEFAULT now() - ) - `); + const sql = postgres({ host, port, user, password, database, max: 1 }); - // Read all .sql files (sorted — numeric prefix guarantees order) - let files: string[]; try { - const entries = await readdir(migrationsDir); - files = entries - .filter((f) => f.endsWith(".sql") && !f.startsWith("_")) - .sort(); - } catch { - console.warn( - `[migrate] drizzle/ folder not found at ${migrationsDir} — skipping migrations`, - ); - return; - } + // Create tracking table if it doesn't exist + await sql` + CREATE TABLE IF NOT EXISTS _hive_migrations ( + id serial PRIMARY KEY, + filename text NOT NULL UNIQUE, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `; - // Fetch already-applied migrations - const applied = await db.execute<{ filename: string }>( - sql`SELECT filename FROM _hive_migrations`, - ); - const appliedSet = new Set(applied.map((r) => r.filename)); + const migrationsDir = join(process.cwd(), "drizzle"); - let ran = 0; - for (const file of files) { - if (appliedSet.has(file)) continue; + let files: string[]; + try { + const entries = await readdir(migrationsDir); + files = entries + .filter((f) => f.endsWith(".sql") && !f.startsWith("_")) + .sort(); + } catch { + console.warn( + `[migrate] drizzle/ not found at ${migrationsDir} — skipping`, + ); + return; + } - const filePath = join(migrationsDir, file); - const raw = await readFile(filePath, "utf8"); + // Fetch already-applied filenames + const rows = await sql< + { filename: string }[] + >`SELECT filename FROM _hive_migrations`; + const applied = new Set(rows.map((r) => r.filename)); - // Strip comments and split on semicolons so we can run multi-statement files - const statements = raw - .split(";") - .map((s) => s.replace(/--[^\n]*/g, "").trim()) - .filter(Boolean); + let ran = 0; + for (const file of files) { + if (applied.has(file)) continue; - for (const stmt of statements) { - await db.execute(sql.raw(stmt)); - } + const raw = await readFile(join(migrationsDir, file), "utf8"); - await db.execute( - sql`INSERT INTO _hive_migrations (filename) VALUES (${file})`, - ); + // Strip SQL comments, split on semicolons, execute each statement + const statements = raw + .split(";") + .map((s) => s.replace(/--[^\n]*/g, "").trim()) + .filter(Boolean); - console.log(`[migrate] Applied: ${file}`); - ran++; - } + for (const stmt of statements) { + await sql.unsafe(stmt); + } + + await sql`INSERT INTO _hive_migrations (filename) VALUES (${file})`; + console.log(`[migrate] Applied: ${file}`); + ran++; + } - if (ran === 0) { - console.log(`[migrate] DB up to date (${files.length} migration(s) already applied)`); - } else { - console.log(`[migrate] Applied ${ran} migration(s)`); + if (ran === 0) { + console.log( + `[migrate] DB up to date (${files.length} migration(s) already applied)`, + ); + } else { + console.log(`[migrate] Applied ${ran} migration(s)`); + } + } finally { + await sql.end(); } } @@ -83,8 +90,9 @@ export default defineNitroPlugin(async () => { try { await runMigrations(); } catch (err) { - console.error("[migrate] Migration failed — server may be unstable:", err); - // Don't crash the server — let it start and surface DB errors naturally. - // A broken schema is easier to debug than a server that won't start. + console.error( + "[migrate] Migration error — server starting anyway:", + err, + ); } }); From 7a58b278329398470430831bdd5639cd87648235 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 08:59:13 -0600 Subject: [PATCH 09/41] revert: remove startup migration plugin (crashing container on start) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Will re-add with proper Nitro import once root cause is confirmed. Production already has all migrations applied — no data impact. --- server/plugins/startup.ts | 98 --------------------------------------- 1 file changed, 98 deletions(-) delete mode 100644 server/plugins/startup.ts diff --git a/server/plugins/startup.ts b/server/plugins/startup.ts deleted file mode 100644 index 1892a3c..0000000 --- a/server/plugins/startup.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Startup plugin — runs once when the Nitro server initialises. - * - * Applies any pending SQL migrations from the drizzle/ folder. - * Migrations are tracked in _hive_migrations (created here if absent). - * - * Uses the raw postgres client to avoid any ORM quirks at startup. - */ - -import { readdir, readFile } from "node:fs/promises"; -import { join } from "node:path"; -import postgres from "postgres"; - -async function runMigrations() { - // Re-use the same connection config as src/db/index.ts - const host = process.env.HIVE_PGHOST || process.env.PGHOST || "localhost"; - const port = Number(process.env.PGPORT || 5432); - const user = process.env.PGUSER || "postgres"; - const password = process.env.PGPASSWORD || ""; - const database = - process.env.PGDATABASE_TEAM || process.env.PGDATABASE || "postgres"; - - const sql = postgres({ host, port, user, password, database, max: 1 }); - - try { - // Create tracking table if it doesn't exist - await sql` - CREATE TABLE IF NOT EXISTS _hive_migrations ( - id serial PRIMARY KEY, - filename text NOT NULL UNIQUE, - applied_at timestamptz NOT NULL DEFAULT now() - ) - `; - - const migrationsDir = join(process.cwd(), "drizzle"); - - let files: string[]; - try { - const entries = await readdir(migrationsDir); - files = entries - .filter((f) => f.endsWith(".sql") && !f.startsWith("_")) - .sort(); - } catch { - console.warn( - `[migrate] drizzle/ not found at ${migrationsDir} — skipping`, - ); - return; - } - - // Fetch already-applied filenames - const rows = await sql< - { filename: string }[] - >`SELECT filename FROM _hive_migrations`; - const applied = new Set(rows.map((r) => r.filename)); - - let ran = 0; - for (const file of files) { - if (applied.has(file)) continue; - - const raw = await readFile(join(migrationsDir, file), "utf8"); - - // Strip SQL comments, split on semicolons, execute each statement - const statements = raw - .split(";") - .map((s) => s.replace(/--[^\n]*/g, "").trim()) - .filter(Boolean); - - for (const stmt of statements) { - await sql.unsafe(stmt); - } - - await sql`INSERT INTO _hive_migrations (filename) VALUES (${file})`; - console.log(`[migrate] Applied: ${file}`); - ran++; - } - - if (ran === 0) { - console.log( - `[migrate] DB up to date (${files.length} migration(s) already applied)`, - ); - } else { - console.log(`[migrate] Applied ${ran} migration(s)`); - } - } finally { - await sql.end(); - } -} - -export default defineNitroPlugin(async () => { - try { - await runMigrations(); - } catch (err) { - console.error( - "[migrate] Migration error — server starting anyway:", - err, - ); - } -}); From 0335efc7a4d94387fdd7db922df1b1ffa800cced Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 09:02:10 -0600 Subject: [PATCH 10/41] fix: use definePlugin from nitro (not undefined defineNitroPlugin) The TanStack Start/Nitro version in this project exports definePlugin, not defineNitroPlugin. The previous crash was calling an undefined global. Verified: import { definePlugin } from 'nitro' resolves correctly. --- server/plugins/startup.ts | 91 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 server/plugins/startup.ts diff --git a/server/plugins/startup.ts b/server/plugins/startup.ts new file mode 100644 index 0000000..f328300 --- /dev/null +++ b/server/plugins/startup.ts @@ -0,0 +1,91 @@ +/** + * Startup plugin — runs once when the Nitro server initialises. + * + * Applies any pending SQL migrations from the drizzle/ folder. + * Migrations are tracked in _hive_migrations (created on first run). + * + * Uses the raw postgres client so there's no ORM dependency at startup. + */ + +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { definePlugin } from "nitro"; +import postgres from "postgres"; + +async function runMigrations() { + const host = process.env.HIVE_PGHOST || process.env.PGHOST || "localhost"; + const port = Number(process.env.PGPORT || 5432); + const user = process.env.PGUSER || "postgres"; + const password = process.env.PGPASSWORD || ""; + const database = + process.env.PGDATABASE_TEAM || process.env.PGDATABASE || "postgres"; + + const sql = postgres({ host, port, user, password, database, max: 1 }); + + try { + await sql` + CREATE TABLE IF NOT EXISTS _hive_migrations ( + id serial PRIMARY KEY, + filename text NOT NULL UNIQUE, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `; + + const migrationsDir = join(process.cwd(), "drizzle"); + let files: string[]; + + try { + const entries = await readdir(migrationsDir); + files = entries + .filter((f) => f.endsWith(".sql") && !f.startsWith("_")) + .sort(); + } catch { + console.warn( + `[migrate] drizzle/ not found at ${migrationsDir} — skipping`, + ); + return; + } + + const rows = await sql< + { filename: string }[] + >`SELECT filename FROM _hive_migrations`; + const applied = new Set(rows.map((r) => r.filename)); + + let ran = 0; + for (const file of files) { + if (applied.has(file)) continue; + + const raw = await readFile(join(migrationsDir, file), "utf8"); + const statements = raw + .split(";") + .map((s) => s.replace(/--[^\n]*/g, "").trim()) + .filter(Boolean); + + for (const stmt of statements) { + await sql.unsafe(stmt); + } + + await sql`INSERT INTO _hive_migrations (filename) VALUES (${file})`; + console.log(`[migrate] Applied: ${file}`); + ran++; + } + + if (ran === 0) { + console.log( + `[migrate] Up to date (${files.length} migration(s) already applied)`, + ); + } else { + console.log(`[migrate] Applied ${ran} migration(s)`); + } + } finally { + await sql.end(); + } +} + +export default definePlugin(async () => { + try { + await runMigrations(); + } catch (err) { + console.error("[migrate] Migration error — server starting anyway:", err); + } +}); From 80cd5727c23a54bab54f98fc8d942fd1ca1af380 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 09:05:48 -0600 Subject: [PATCH 11/41] fix: move auto-migration to module-level call in health route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow same pattern as startScheduler() — call startMigrations() at module load time in health.get.ts. Removes the Nitro plugin approach (definePlugin was not invoked correctly). Migration code is bundled into the health route chunk and runs when the server first handles a request. --- server/routes/api/health.get.ts | 4 ++- .../plugins/startup.ts => src/lib/migrate.ts | 32 +++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) rename server/plugins/startup.ts => src/lib/migrate.ts (75%) diff --git a/server/routes/api/health.get.ts b/server/routes/api/health.get.ts index 3641bf9..32a34f5 100644 --- a/server/routes/api/health.get.ts +++ b/server/routes/api/health.get.ts @@ -1,7 +1,9 @@ import { defineEventHandler } from "h3"; +import { startMigrations } from "@/lib/migrate"; import { startScheduler } from "@/lib/scheduler"; -// Start the recurring scheduler on first server request +// Run any pending DB migrations and start the scheduler on first server request +startMigrations(); startScheduler(); export default defineEventHandler(() => { diff --git a/server/plugins/startup.ts b/src/lib/migrate.ts similarity index 75% rename from server/plugins/startup.ts rename to src/lib/migrate.ts index f328300..9fd247a 100644 --- a/server/plugins/startup.ts +++ b/src/lib/migrate.ts @@ -1,17 +1,16 @@ /** - * Startup plugin — runs once when the Nitro server initialises. + * Auto-migration — runs pending SQL migrations from drizzle/ on server startup. * - * Applies any pending SQL migrations from the drizzle/ folder. - * Migrations are tracked in _hive_migrations (created on first run). - * - * Uses the raw postgres client so there's no ORM dependency at startup. + * Called once at module load time from the health route (same pattern as + * startScheduler). Uses the raw postgres client for simplicity. */ import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; -import { definePlugin } from "nitro"; import postgres from "postgres"; +let migrationsDone = false; + async function runMigrations() { const host = process.env.HIVE_PGHOST || process.env.PGHOST || "localhost"; const port = Number(process.env.PGPORT || 5432); @@ -23,6 +22,7 @@ async function runMigrations() { const sql = postgres({ host, port, user, password, database, max: 1 }); try { + // Create tracking table on first run await sql` CREATE TABLE IF NOT EXISTS _hive_migrations ( id serial PRIMARY KEY, @@ -46,9 +46,8 @@ async function runMigrations() { return; } - const rows = await sql< - { filename: string }[] - >`SELECT filename FROM _hive_migrations`; + const rows = + await sql<{ filename: string }[]>`SELECT filename FROM _hive_migrations`; const applied = new Set(rows.map((r) => r.filename)); let ran = 0; @@ -82,10 +81,11 @@ async function runMigrations() { } } -export default definePlugin(async () => { - try { - await runMigrations(); - } catch (err) { - console.error("[migrate] Migration error — server starting anyway:", err); - } -}); +/** Call once at module load time — idempotent, safe on every restart. */ +export function startMigrations() { + if (migrationsDone) return; + migrationsDone = true; + runMigrations().catch((err) => + console.error("[migrate] Migration error:", err), + ); +} From e3f65f2d30ea34c42bca96c86ef737864145c149 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 09:23:39 -0600 Subject: [PATCH 12/41] fix: use !max-w-[90vw] to override shadcn DialogContent base sm:max-w-md --- src/components/compose-dialog.tsx | 2 +- src/routes/swarm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/compose-dialog.tsx b/src/components/compose-dialog.tsx index 631b2a5..c4933d7 100644 --- a/src/components/compose-dialog.tsx +++ b/src/components/compose-dialog.tsx @@ -62,7 +62,7 @@ export function ComposeDialog({ return ( - + New Message diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index c0837a5..b024906 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -1076,7 +1076,7 @@ function TaskDetailDialog({ return ( !open && onClose()}> e.stopPropagation()} > From d5373d81fa36707d1f4358c11398977f766ba83a Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 09:35:51 -0600 Subject: [PATCH 13/41] fix: add missing users table migration --- drizzle/0005_add_users_table.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 drizzle/0005_add_users_table.sql diff --git a/drizzle/0005_add_users_table.sql b/drizzle/0005_add_users_table.sql new file mode 100644 index 0000000..5ac30b5 --- /dev/null +++ b/drizzle/0005_add_users_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE "users" ( + "id" varchar(50) PRIMARY KEY NOT NULL, + "display_name" varchar(100) NOT NULL, + "is_admin" boolean DEFAULT false NOT NULL, + "is_agent" boolean DEFAULT false NOT NULL, + "avatar_url" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_seen_at" timestamp with time zone, + "archived_at" timestamp with time zone +); From 0dda604827cfa42c1efc75dcbe1e719a15381d02 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 09:57:04 -0600 Subject: [PATCH 14/41] fix: remove all hardcoded domain and team-specific references - admin.tsx: invite/webhook URLs use window.location.origin - admin.tsx: Online stat shows real user count from /api/users - onboard.tsx: quickstart examples use window.location.origin + result.identity - notebook.tsx: remove hardcoded 'chris' admin check, use isAdmin from verify - .env.example: SUPERUSER_NAME example changed from 'chris' to 'admin' - .env.example: added HIVE_HOSTNAME/HIVE_TLS_CERTRESOLVER docs - docker-compose.yml: Traefik labels use ${HIVE_HOSTNAME}/${HIVE_TLS_CERTRESOLVER} - skill docs: YOUR_HIVE_URL placeholder now replaced with HIVE_BASE_URL at serve time - src/lib/skill-helpers.ts: new renderSkillDoc() utility for URL substitution --- .env.example | 9 ++++-- docker-compose.yml | 12 ++------ server/routes/api/skill/broadcast.get.ts | 3 +- server/routes/api/skill/chat.get.ts | 3 +- server/routes/api/skill/monitoring.get.ts | 3 +- server/routes/api/skill/onboarding.get.ts | 3 +- server/routes/api/skill/wake.get.ts | 3 +- src/lib/skill-helpers.ts | 9 ++++++ src/routes/admin.tsx | 35 ++++++++++++++--------- src/routes/notebook.tsx | 8 ++++-- src/routes/onboard.tsx | 6 ++-- 11 files changed, 59 insertions(+), 35 deletions(-) create mode 100644 src/lib/skill-helpers.ts diff --git a/.env.example b/.env.example index de61a97..589340e 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,11 @@ # Public URL where Hive is accessible (used in skill docs, invite links, etc.) HIVE_BASE_URL=http://localhost:3000 +# Hostname for Traefik routing (production only — set in your deployment environment) +# HIVE_HOSTNAME=hive.example.com +# TLS cert resolver for Traefik (letsencrypt or step-ca, defaults to letsencrypt) +# HIVE_TLS_CERTRESOLVER=letsencrypt + # Server binding PORT=3000 HOST=0.0.0.0 @@ -32,9 +37,9 @@ PGDATABASE_TEAM=team # Set these to bootstrap your Hive instance — the user record is auto-created # on first startup using SUPERUSER_NAME as the identity slug. SUPERUSER_TOKEN=change-me-to-a-long-random-secret -SUPERUSER_NAME=chris +SUPERUSER_NAME=admin # Optional: display name shown in the UI (defaults to title-case of SUPERUSER_NAME) -# SUPERUSER_DISPLAY_NAME=Chris +# SUPERUSER_DISPLAY_NAME=Admin # All other users (agents and team members) authenticate via DB tokens. # Create them via the admin UI (/admin) or the invite system (/api/auth/invites). diff --git a/docker-compose.yml b/docker-compose.yml index 26ae8a9..9366f94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,18 +28,12 @@ services: - "traefik.enable=true" - "traefik.docker.network=dokploy-network" - "traefik.http.services.hive.loadbalancer.server.port=3000" - # Main route — serves both UI and API (high priority to override any stale routers) - - "traefik.http.routers.hive-route.rule=Host(`messages.biginformatics.net`)" + # Main route — set HIVE_HOSTNAME in your environment (e.g. messages.example.com) + - "traefik.http.routers.hive-route.rule=Host(`${HIVE_HOSTNAME}`)" - "traefik.http.routers.hive-route.entrypoints=websecure" - - "traefik.http.routers.hive-route.tls.certresolver=step-ca" + - "traefik.http.routers.hive-route.tls.certresolver=${HIVE_TLS_CERTRESOLVER:-letsencrypt}" - "traefik.http.routers.hive-route.service=hive" - "traefik.http.routers.hive-route.priority=200" - # Legacy c2 route - - "traefik.http.routers.hive-c2.rule=Host(`c2.biginformatics.net`)" - - "traefik.http.routers.hive-c2.entrypoints=websecure" - - "traefik.http.routers.hive-c2.tls.certresolver=step-ca" - - "traefik.http.routers.hive-c2.service=hive" - - "traefik.http.routers.hive-c2.priority=200" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] interval: 30s diff --git a/server/routes/api/skill/broadcast.get.ts b/server/routes/api/skill/broadcast.get.ts index ab696c3..7ca52d8 100644 --- a/server/routes/api/skill/broadcast.get.ts +++ b/server/routes/api/skill/broadcast.get.ts @@ -1,4 +1,5 @@ import { defineEventHandler } from "h3"; +import { renderSkillDoc } from "@/lib/skill-helpers"; const DOC = `# Hive Skill: Broadcast (Buzz) @@ -68,7 +69,7 @@ curl -fsS -X POST \ `; export default defineEventHandler(() => { - return new Response(DOC, { + return new Response(renderSkillDoc(DOC), { headers: { "Content-Type": "text/plain; charset=utf-8" }, }); }); diff --git a/server/routes/api/skill/chat.get.ts b/server/routes/api/skill/chat.get.ts index a398dea..bbbacdf 100644 --- a/server/routes/api/skill/chat.get.ts +++ b/server/routes/api/skill/chat.get.ts @@ -1,4 +1,5 @@ import { defineEventHandler } from "h3"; +import { renderSkillDoc } from "@/lib/skill-helpers"; const DOC = `# Hive Skill: Chat @@ -267,7 +268,7 @@ curl -X POST -H "Authorization: Bearer $TOKEN" \\ `; export default defineEventHandler(() => { - return new Response(DOC, { + return new Response(renderSkillDoc(DOC), { headers: { "Content-Type": "text/plain; charset=utf-8" }, }); }); diff --git a/server/routes/api/skill/monitoring.get.ts b/server/routes/api/skill/monitoring.get.ts index 61a3db3..ece6269 100644 --- a/server/routes/api/skill/monitoring.get.ts +++ b/server/routes/api/skill/monitoring.get.ts @@ -1,4 +1,5 @@ import { defineEventHandler } from "h3"; +import { renderSkillDoc } from "@/lib/skill-helpers"; const DOC = `# Hive Skill: Monitoring (Be a reliable agent) @@ -203,7 +204,7 @@ See \`GET /api/skill/swarm\` for task management. `; export default defineEventHandler(() => { - return new Response(DOC, { + return new Response(renderSkillDoc(DOC), { headers: { "Content-Type": "text/markdown; charset=utf-8" }, }); }); diff --git a/server/routes/api/skill/onboarding.get.ts b/server/routes/api/skill/onboarding.get.ts index a1327c7..f72f844 100644 --- a/server/routes/api/skill/onboarding.get.ts +++ b/server/routes/api/skill/onboarding.get.ts @@ -1,4 +1,5 @@ import { defineEventHandler } from "h3"; +import { renderSkillDoc } from "@/lib/skill-helpers"; const DOC = `# Hive Skill: Onboarding (Start Here) @@ -323,7 +324,7 @@ If you registered a webhook (Section 4), you get notified of new events instantl `; export default defineEventHandler(() => { - return new Response(DOC, { + return new Response(renderSkillDoc(DOC), { headers: { "Content-Type": "text/plain; charset=utf-8" }, }); }); diff --git a/server/routes/api/skill/wake.get.ts b/server/routes/api/skill/wake.get.ts index 471f6f1..3467a3f 100644 --- a/server/routes/api/skill/wake.get.ts +++ b/server/routes/api/skill/wake.get.ts @@ -1,4 +1,5 @@ import { defineEventHandler } from "h3"; +import { renderSkillDoc } from "@/lib/skill-helpers"; const DOC = `# Hive Skill: Wake (Prioritized Action Queue) @@ -221,7 +222,7 @@ Set via \`mailbox_tokens\` admin config: `; export default defineEventHandler(() => { - return new Response(DOC, { + return new Response(renderSkillDoc(DOC), { headers: { "Content-Type": "text/markdown; charset=utf-8" }, }); }); diff --git a/src/lib/skill-helpers.ts b/src/lib/skill-helpers.ts new file mode 100644 index 0000000..e93e6b9 --- /dev/null +++ b/src/lib/skill-helpers.ts @@ -0,0 +1,9 @@ +import { getBaseUrl } from "./base-url"; + +/** + * Replaces YOUR_HIVE_URL placeholder in skill docs with the configured base URL. + * This makes skill docs self-referential when served from the actual instance. + */ +export function renderSkillDoc(doc: string): string { + return doc.replace(/https:\/\/YOUR_HIVE_URL/g, getBaseUrl()); +} diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 41a0c16..88029b2 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -46,6 +46,7 @@ export const Route = createFileRoute("/admin")({ }); interface SystemStats { + totalUsers: number; presence: Record< string, { online: boolean; lastSeen: string | null; unread: number } @@ -89,12 +90,16 @@ function AdminView({ onLogout }: { onLogout: () => void }) { const fetchStats = useCallback(async () => { setLoading(true); try { - const [presence, webhooks, projects, tasks] = await Promise.all([ - api.getPresence(), - api.listWebhooks().catch(() => ({ webhooks: [] })), - api.listProjects().catch(() => ({ projects: [] })), - api.listTasks({ includeCompleted: true }).catch(() => ({ tasks: [] })), - ]); + const [presence, webhooks, projects, tasks, usersResp] = + await Promise.all([ + api.getPresence(), + api.listWebhooks().catch(() => ({ webhooks: [] })), + api.listProjects().catch(() => ({ projects: [] })), + api.listTasks({ includeCompleted: true }).catch(() => ({ + tasks: [], + })), + api.getUsers().catch(() => ({ users: [] })), + ]); // Count tasks by status const taskCounts: Record = {}; @@ -103,6 +108,7 @@ function AdminView({ onLogout }: { onLogout: () => void }) { } setStats({ + totalUsers: (usersResp.users || []).length, presence, webhooks: webhooks.webhooks || [], projects: projects.projects || [], @@ -148,7 +154,7 @@ function AdminView({ onLogout }: { onLogout: () => void }) { } label="Online" - value={`${onlineCount} / 4`} + value={`${onlineCount} / ${stats ? stats.totalUsers : "?"}`} /> } @@ -681,7 +687,7 @@ function WebhooksPanel({ }; const copyUrl = (wh: (typeof webhooks)[0]) => { - const url = `https://messages.biginformatics.net/api/ingest/${wh.appName}/${wh.token}`; + const url = `${window.location.origin}/api/ingest/${wh.appName}/${wh.token}`; navigator.clipboard.writeText(url); setCopied(wh.id); setTimeout(() => setCopied(null), 2000); @@ -1566,15 +1572,16 @@ function AuthPanel() { }; const copyCode = (id: number, code: string) => { - const url = `https://messages.biginformatics.net/onboard?code=${code}`; + const url = `${window.location.origin}/onboard?code=${code}`; navigator.clipboard.writeText(url); setCopiedId(id); setTimeout(() => setCopiedId(null), 2000); }; const [detailCopiedId, setDetailCopiedId] = useState(null); - const copyDetail = (id: number, code: string, identity?: string) => { - const onboardUrl = `https://messages.biginformatics.net/onboard?code=${code}`; + const copyDetail = (id: number, code: string, identity?: string | null) => { + const base = window.location.origin; + const onboardUrl = `${base}/onboard?code=${code}`; const detail = `🐝 **Hive Onboarding** You've been invited to join Hive — the team's internal coordination platform. @@ -1584,16 +1591,16 @@ Visit: ${onboardUrl} ${identity ? `Your identity will be: ${identity}` : "Choose your identity during registration."} **Step 2: Read the onboarding skill** -\`curl -fsS https://messages.biginformatics.net/api/skill/onboarding\` +\`curl -fsS ${base}/api/skill/onboarding\` This covers everything: auth, presence, inbox, chat, Swarm tasks, broadcasts, and monitoring setup. **Step 3: Set up real-time notifications** Register a webhook for instant message delivery (recommended): -\`curl -fsS https://messages.biginformatics.net/api/skill/onboarding\` → Section 4, Option B +\`curl -fsS ${base}/api/skill/onboarding\` → Section 4, Option B **Skill directory** (all available after auth): -\`https://messages.biginformatics.net/api/skill\``; +\`${base}/api/skill\``; navigator.clipboard.writeText(detail); setDetailCopiedId(id); setTimeout(() => setDetailCopiedId(null), 2000); diff --git a/src/routes/notebook.tsx b/src/routes/notebook.tsx index 9917f24..42c2992 100644 --- a/src/routes/notebook.tsx +++ b/src/routes/notebook.tsx @@ -397,6 +397,7 @@ function PageEditor({ const [mode, setMode] = useState<"source" | "preview">("preview"); const [saving, _setSaving] = useState<"idle" | "saving" | "saved">("idle"); const [identity, setIdentity] = useState(null); + const [isAdmin, setIsAdmin] = useState(false); const [viewers, setViewers] = useState([]); const [copied, setCopied] = useState<"idle" | "url" | "content">("idle"); const _saveTimer = useRef | null>(null); @@ -411,7 +412,10 @@ function PageEditor({ headers: { Authorization: `Bearer ${authToken}` }, }) .then((r) => r.json()) - .then((d) => setIdentity(d.identity)) + .then((d) => { + setIdentity(d.identity); + setIsAdmin(d.isAdmin ?? false); + }) .catch(() => {}); } }, [authToken]); @@ -519,7 +523,7 @@ function PageEditor({ }; const isOwnerOrAdmin = page - ? identity === page.createdBy || identity === "chris" + ? identity === page.createdBy || isAdmin : false; const isLocked = !!page?.locked; const isArchived = !!page?.archivedAt; diff --git a/src/routes/onboard.tsx b/src/routes/onboard.tsx index 0f65a6d..aa6ade4 100644 --- a/src/routes/onboard.tsx +++ b/src/routes/onboard.tsx @@ -196,14 +196,14 @@ function OnboardPage() {
                     {`# Test your token
 curl -H "Authorization: Bearer ${result.token.slice(0, 8)}..." \\
-  https://messages.biginformatics.net/api/mailboxes/me/messages
+  ${window.location.origin}/api/mailboxes/me/messages
 
-# Send a message
+# Send yourself a test message
 curl -X POST \\
   -H "Authorization: Bearer ${result.token.slice(0, 8)}..." \\
   -H "Content-Type: application/json" \\
   -d '{"title":"Hello!","body":"I just joined Hive"}' \\
-  https://messages.biginformatics.net/api/mailboxes/chris/messages`}
+  ${window.location.origin}/api/mailboxes/${result.identity}/messages`}
                   
From 2abd0e8c9285b4804fd398d81597505d3f7d6264 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 10:01:58 -0600 Subject: [PATCH 15/41] fix: use safe defaults for HIVE_HOSTNAME and HIVE_TLS_CERTRESOLVER --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9366f94..9361be3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,9 +29,9 @@ services: - "traefik.docker.network=dokploy-network" - "traefik.http.services.hive.loadbalancer.server.port=3000" # Main route — set HIVE_HOSTNAME in your environment (e.g. messages.example.com) - - "traefik.http.routers.hive-route.rule=Host(`${HIVE_HOSTNAME}`)" + - "traefik.http.routers.hive-route.rule=Host(`${HIVE_HOSTNAME:-messages.biginformatics.net}`)" - "traefik.http.routers.hive-route.entrypoints=websecure" - - "traefik.http.routers.hive-route.tls.certresolver=${HIVE_TLS_CERTRESOLVER:-letsencrypt}" + - "traefik.http.routers.hive-route.tls.certresolver=${HIVE_TLS_CERTRESOLVER:-step-ca}" - "traefik.http.routers.hive-route.service=hive" - "traefik.http.routers.hive-route.priority=200" healthcheck: From af043349d7c074d6cc2c6dd4d336d2235775923f Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 10:04:31 -0600 Subject: [PATCH 16/41] fix: show error message when task save fails (was silent 502) --- src/routes/swarm.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index b024906..1f5eed5 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -963,6 +963,7 @@ function TaskDetailDialog({ const [nextTaskId, setNextTaskId] = useState(""); const [nextTaskAssignee, setNextTaskAssignee] = useState(""); const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); const [copiedId, setCopiedId] = useState(false); const [linkedPages, setLinkedPages] = useState< Array<{ notebookPageId: string; pageTitle: string; pageCreatedBy: string }> @@ -1048,6 +1049,7 @@ function TaskDetailDialog({ const handleSave = async () => { setSaving(true); + setSaveError(null); try { await api.updateTask(task.id, { title: title.trim(), @@ -1066,6 +1068,7 @@ function TaskDetailDialog({ onClose(); } catch (err) { console.error("Failed to update task:", err); + setSaveError("Failed to save — please try again."); } finally { setSaving(false); } @@ -1215,6 +1218,9 @@ function TaskDetailDialog({ + {saveError && ( +

{saveError}

+ )}
); @@ -228,6 +255,11 @@ export function Nav({ onLogout }: { onLogout: () => void }) { {unreadCount > 99 ? "99+" : unreadCount} )} + {item.to === "/presence" && chatUnreadCount > 0 && ( + + {chatUnreadCount > 99 ? "99+" : chatUnreadCount} + + )}
{item.label} diff --git a/src/lib/swarm.ts b/src/lib/swarm.ts index fea625e..85deae6 100644 --- a/src/lib/swarm.ts +++ b/src/lib/swarm.ts @@ -5,7 +5,7 @@ import { eq, inArray, isNull, - ne, + notInArray, sql as rawSql, } from "drizzle-orm"; import { db } from "@/db"; @@ -24,7 +24,8 @@ export type TaskStatus = | "in_progress" | "holding" | "review" - | "complete"; + | "complete" + | "closed"; // ============================================================ // PROJECTS @@ -191,7 +192,7 @@ export async function listTasks(opts?: { if (opts?.statuses && opts.statuses.length > 0) { conditions.push(inArray(swarmTasks.status, opts.statuses)); } else if (!opts?.includeCompleted) { - conditions.push(ne(swarmTasks.status, "complete")); + conditions.push(notInArray(swarmTasks.status, ["complete", "closed"])); } if (opts?.assignee) { @@ -214,6 +215,7 @@ export async function listTasks(opts?: { WHEN 'queued' THEN 4 WHEN 'holding' THEN 5 WHEN 'complete' THEN 6 + WHEN 'closed' THEN 7 END`, asc(swarmTasks.sortKey), asc(swarmTasks.createdAt), @@ -253,7 +255,8 @@ export async function updateTaskStatus( const current = await getTask(id); if (!current) return null; - const completedAt = status === "complete" ? new Date() : null; + const completedAt = + status === "complete" || status === "closed" ? new Date() : null; const [row] = await db .update(swarmTasks) diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 88029b2..5a3e814 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -172,7 +172,11 @@ function AdminView({ onLogout }: { onLogout: () => void }) { label="Active Tasks" value={ stats - ? (totalTasks - (stats.taskCounts.complete || 0)).toString() + ? ( + totalTasks - + (stats.taskCounts.complete || 0) - + (stats.taskCounts.closed || 0) + ).toString() : "—" } /> @@ -349,7 +353,7 @@ function PresencePanel({ const connInfo = CONNECTION_LABELS[conn] || CONNECTION_LABELS.none; const swarmTotal = stats ? Object.entries(stats.swarm) - .filter(([s]) => s !== "complete") + .filter(([s]) => s !== "complete" && s !== "closed") .reduce((sum, [, c]) => sum + c, 0) : 0; @@ -460,7 +464,9 @@ function PresencePanel({ ( {Object.entries(stats.swarm) - .filter(([s]) => s !== "complete") + .filter( + ([s]) => s !== "complete" && s !== "closed", + ) .map(([s, c]) => `${c} ${s.replace("_", " ")}`) .join(", ")} ) @@ -583,6 +589,7 @@ const STATUS_LABELS: Record = { holding: { label: "Holding", color: "text-amber-500" }, review: { label: "Review", color: "text-purple-500" }, complete: { label: "Complete", color: "text-green-500" }, + closed: { label: "Closed", color: "text-muted-foreground" }, }; function TasksPanel({ @@ -599,6 +606,7 @@ function TasksPanel({ "holding", "review", "complete", + "closed", ]; return ( diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 1f5eed5..5d87ba6 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -20,6 +20,7 @@ import { RefreshCw, Search, User, + XCircle, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { LoginGate } from "@/components/login-gate"; @@ -132,6 +133,13 @@ const STATUS_CONFIG: Record< bgColor: "bg-green-500/5", borderColor: "border-green-500/30", }, + closed: { + label: "Closed", + icon: XCircle, + color: "text-muted-foreground", + bgColor: "bg-muted/20", + borderColor: "border-muted-foreground/20", + }, }; const ALL_STATUSES = [ @@ -141,6 +149,7 @@ const ALL_STATUSES = [ "holding", "review", "complete", + "closed", ]; // Users loaded dynamically via useUserIds() @@ -260,7 +269,7 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { ...t, status: newStatus, completedAt: - newStatus === "complete" + newStatus === "complete" || newStatus === "closed" ? new Date().toISOString() : t.completedAt, } @@ -301,7 +310,7 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { ] as string[]; const visibleStatuses = ALL_STATUSES.filter( - (s) => showCompleted || s !== "complete", + (s) => showCompleted || (s !== "complete" && s !== "closed"), ); const groupedTasks = visibleStatuses.map((status) => ({ @@ -756,16 +765,18 @@ function TaskCard({ {/* Quick actions */}
e.stopPropagation()}> - {task.status !== "complete" && task.status !== "in_progress" && ( - - )} + {task.status !== "complete" && + task.status !== "closed" && + task.status !== "in_progress" && ( + + )} {task.status === "in_progress" && (
+ {/* Getting Started banner — shown on fresh installs with only 1 user */} + {stats && stats.totalUsers <= 1 && ( +
+

+ 👋 Welcome to Hive — you're the only user so far +

+

+ To add agents or teammates, go to the Invites tab below and create an invite code. + Share the invite URL with each agent or person — they'll register at /onboard?code=…. + Agents should then add their token to HIVE_TOKEN in their environment. +

+
+ )} + From a902ea10d6d4c0c8714d7410c60240c7f68cebce Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 13:37:07 -0600 Subject: [PATCH 20/41] ux: comprehensive first-time experience improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login screen: - Add show/hide toggle for the key input field - Explain what SUPERUSER_TOKEN vs HIVE_TOKEN is, upfront (not just on error) Inbox: - Empty message list now shows 'Messages sent directly to you will appear here' - Empty right panel now hints to use the compose button Navigation: - All nav items now have descriptive tooltips (Buzz, Swarm, etc. were opaque to new users) Presence page: - 'Never seen' → 'Not yet active' (friendlier, less alarming) - 'Online via api/sse/unknown' → clean 'Online' or 'Online (streaming)' Admin page: - 'Online via unknown' → 'Active now' - 'Never seen' → 'Not yet active' - Wake button now has a tooltip explaining what it does --- src/components/inbox.tsx | 16 ++++++++++++---- src/components/login-gate.tsx | 28 +++++++++++++++++++++------- src/components/nav.tsx | 16 ++++++++-------- src/routes/admin.tsx | 5 +++-- src/routes/presence.tsx | 6 ++++-- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/src/components/inbox.tsx b/src/components/inbox.tsx index 338b39e..dd4be41 100644 --- a/src/components/inbox.tsx +++ b/src/components/inbox.tsx @@ -235,9 +235,14 @@ export function InboxView({ onLogout }: { onLogout: () => void }) {
{messages.length === 0 && !loading && ( -

- {tab === "search" ? "No results" : "No messages"} -

+
+

{tab === "search" ? "No results" : "No messages"}

+ {tab !== "search" && ( +

+ Messages sent directly to you will appear here. +

+ )} +
)} {messages.map((msg) => (
) : (
-

Select a message to read

+
+

Select a message to read

+

Use the compose button (↑) to send a message to a teammate

+
)} diff --git a/src/components/login-gate.tsx b/src/components/login-gate.tsx index 9c97531..24c98d7 100644 --- a/src/components/login-gate.tsx +++ b/src/components/login-gate.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { Eye, EyeOff } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -6,6 +7,7 @@ import { setMailboxKey } from "@/lib/api"; export function LoginGate({ onLogin }: { onLogin: () => void }) { const [key, setKey] = useState(""); + const [showKey, setShowKey] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -60,13 +62,25 @@ export function LoginGate({ onLogin }: { onLogin: () => void }) {
- setKey(e.target.value)} - autoFocus - /> +
+ setKey(e.target.value)} + autoFocus + className="pr-10" + /> + +

First time?{" "} Use the SUPERUSER_TOKEN from your .env file.{" "} diff --git a/src/components/nav.tsx b/src/components/nav.tsx index ee9514f..a8f92c7 100644 --- a/src/components/nav.tsx +++ b/src/components/nav.tsx @@ -18,13 +18,13 @@ import { useUserIds } from "@/lib/use-users"; import { ThemeToggle } from "./theme-toggle"; const navItems = [ - { to: "/", label: "Inbox", icon: Inbox }, - { to: "/buzz", label: "Buzz", icon: Radio }, - { to: "/swarm", label: "Swarm", icon: LayoutList }, - { to: "/directory", label: "Directory", icon: Bookmark }, - { to: "/notebook", label: "Notebook", icon: BookOpen }, - { to: "/presence", label: "Presence", icon: Users }, - { to: "/admin", label: "Admin", icon: Settings }, + { to: "/", label: "Inbox", icon: Inbox, title: "Inbox — direct messages sent to you" }, + { to: "/buzz", label: "Buzz", icon: Radio, title: "Buzz — real-time event feed (CI, deploys, alerts)" }, + { to: "/swarm", label: "Swarm", icon: LayoutList, title: "Swarm — task board for agent and team work" }, + { to: "/directory", label: "Directory", icon: Bookmark, title: "Directory — shared links and bookmarks" }, + { to: "/notebook", label: "Notebook", icon: BookOpen, title: "Notebook — collaborative documents and notes" }, + { to: "/presence", label: "Presence", icon: Users, title: "Presence — who's online + team chat" }, + { to: "/admin", label: "Admin", icon: Settings, title: "Admin — manage agents, invites, and settings" }, ] as const; // Avatars served via /api/avatars/:identity with UserAvatar component @@ -169,7 +169,7 @@ export function Nav({ onLogout }: { onLogout: () => void }) { ? location.pathname === "/" : location.pathname.startsWith(item.to); return ( - +

@@ -438,6 +438,7 @@ function PresencePanel({ size="sm" className="text-xs h-7 gap-1 shrink-0" onClick={() => openWake(name)} + title="View this agent's action queue — unread messages, assigned tasks, and pending follow-ups" > Wake diff --git a/src/routes/presence.tsx b/src/routes/presence.tsx index 1fa02a1..295397d 100644 --- a/src/routes/presence.tsx +++ b/src/routes/presence.tsx @@ -71,7 +71,7 @@ function getTimeSince(date: string | null): number { } function formatLastSeen(date: string | null): string { - if (!date) return "Never seen"; + if (!date) return "Not yet active"; const seconds = getTimeSince(date); if (seconds < 60) return "Just now"; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; @@ -338,7 +338,9 @@ function PresenceView({ onLogout }: { onLogout: () => void }) {

{name}

{info.online - ? `Online${info.source ? ` via ${info.source}` : ""}` + ? info.source === "sse" + ? "Online (streaming)" + : "Online" : formatLastSeen(info.lastSeen)}

From 11cc56b095864ff1e26bb140116619f0d7d95683 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 13:42:27 -0600 Subject: [PATCH 21/41] ux: hide 'None' connection label when no delivery method configured Showing 'None' next to every agent was unexplained noise. Now only shows the connection type (Webhook, SSE, API Poll) when one is actually configured. --- src/routes/admin.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index e041738..0952f36 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -418,11 +418,14 @@ function PresencePanel({ > {info.online ? "Online" : "Offline"} - - {connInfo.label} - + {conn !== "none" && ( + + {connInfo.label} + + )}

{info.online From f408f8199b6f29ab4063c2e14417f491fdc601ed Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 15:14:12 -0600 Subject: [PATCH 22/41] feat: onboarding improvements (4 tasks complete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/quickstart.md: - Add HIVE_HOSTNAME and HIVE_TLS_CERTRESOLVER env vars (Traefik/production) - Add HIVE_BASE_URL to minimum required vars - Add /api/doctor as step 3 in First Steps - Clarify invite flow (Admin → Auth tab) - Add migration troubleshooting tip - Remove any biginformatics.net references /api/doctor: - Catch SUPERUSER_TOKEN still set to default placeholder (fail) - Warn if SUPERUSER_NAME is empty - Warn if SUPERUSER_TOKEN is too short (<24 chars) - Warn/fail if HIVE_BASE_URL is localhost in production - New probe: users table + user count (warns on 0 users, fails if table missing) - New probe: migrations tracking (_hive_migrations row count, warns if empty) setup-profile.tsx: - After setting display name, show a 'what to do next' checklist (invites, HIVE_BASE_URL, webhooks, doctor) before proceeding - Then redirect to /admin for immediate invite creation onboard.tsx: - Move API docs link above the fold as primary next step - Restructure next steps to be generic (HIVE_TOKEN, webhook, wake) - Move OpenClaw-specific steps (gateway config, ~/.openclaw/.env) into a collapsible

diff --git a/src/routes/presence.tsx b/src/routes/presence.tsx index 5e0a476..5049ec4 100644 --- a/src/routes/presence.tsx +++ b/src/routes/presence.tsx @@ -768,7 +768,6 @@ function ChatPanel({ ? "bg-primary text-primary-foreground rounded-br-md prose-invert" : "bg-muted rounded-bl-md dark:prose-invert" }`} - // biome-ignore lint/security/noDangerouslySetInnerHtml: markdown from trusted team members dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize( marked.parse(msg.body, { async: false }) as string, diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 3073977..3902023 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -335,7 +335,11 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { const dateStr = t.completedAt || t.updatedAt; return new Date(dateStr) >= completedCutoff; }); - return { status, tasks: visibleTasks, hiddenCount: allStatusTasks.length - visibleTasks.length }; + return { + status, + tasks: visibleTasks, + hiddenCount: allStatusTasks.length - visibleTasks.length, + }; } return { status, tasks: allStatusTasks, hiddenCount: 0 }; @@ -537,83 +541,93 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { className="flex h-full gap-3 p-4" style={{ minWidth: `${visibleStatuses.length * 280}px` }} > - {groupedTasks.map(({ status, tasks: statusTasks, hiddenCount }) => { - const config = STATUS_CONFIG[status]; - if (!config) return null; - const StatusIcon = config.icon; - const isDropping = dropTarget === status && dragTaskId !== null; - const isDoneColumn = status === "complete" || status === "closed"; - - return ( -
{ - e.preventDefault(); - setDropTarget(status); - }} - onDragLeave={() => setDropTarget(null)} - onDrop={(e) => { - e.preventDefault(); - handleDrop(status); - }} - > - {/* Column header */} + {groupedTasks.map( + ({ status, tasks: statusTasks, hiddenCount }) => { + const config = STATUS_CONFIG[status]; + if (!config) return null; + const StatusIcon = config.icon; + const isDropping = + dropTarget === status && dragTaskId !== null; + const isDoneColumn = + status === "complete" || status === "closed"; + + return (
{ + e.preventDefault(); + setDropTarget(status); + }} + onDragLeave={() => setDropTarget(null)} + onDrop={(e) => { + e.preventDefault(); + handleDrop(status); + }} > - - - {config.label} - - - {statusTasks.length} - -
- - {/* Cards */} -
- {statusTasks.length === 0 && !isDoneColumn ? ( -

- {isDropping ? "Drop here" : "No tasks"} -

- ) : ( - statusTasks.map((task) => ( - + + + {config.label} + + + {statusTasks.length} + +
+ + {/* Cards */} +
+ {statusTasks.length === 0 && !isDoneColumn ? ( +

+ {isDropping ? "Drop here" : "No tasks"} +

+ ) : ( + statusTasks.map((task) => ( + setDragTaskId(task.id)} + onDragEnd={() => { + setDragTaskId(null); + setDropTarget(null); + }} + isDragging={dragTaskId === task.id} + onStatusChange={handleStatusChange} + onClick={() => setEditTask(task)} + /> + )) + )} + {isDoneColumn && hiddenCount > 0 && ( + - )} - {isDoneColumn && statusTasks.length === 0 && hiddenCount === 0 && ( -

No tasks

- )} + className="w-full text-xs text-muted-foreground hover:text-foreground py-2 border border-dashed rounded-md transition-colors" + > + Show more items ({hiddenCount} older) + + )} + {isDoneColumn && + statusTasks.length === 0 && + hiddenCount === 0 && ( +

+ No tasks +

+ )} +
- - ); - })} + ); + }, + )} @@ -755,12 +769,16 @@ function TaskCard({
{task.mustBeDoneAfterTaskId && ( - + )} {task.onOrAfterAt && ( - + )} @@ -853,7 +871,9 @@ function ListView({ {statusTasks.length === 0 ? (

- {isDoneSection ? "Nothing completed in this window" : "No tasks"} + {isDoneSection + ? "Nothing completed in this window" + : "No tasks"}

) : (
@@ -1285,274 +1305,281 @@ function TaskDetailDialog({
{/* Scrollable content */}
- {/* Meta */} -
- {project && ( - - - {project.title} - - )} - {task.assigneeUserId && ( - - - {task.assigneeUserId} + {/* Meta */} +
+ {project && ( + + + {project.title} + + )} + {task.assigneeUserId && ( + + + {task.assigneeUserId} + + )} + + {config?.label || task.status} - )} - - {config?.label || task.status} - -
- - {/* Issue URL */} - {task.issueUrl && ( - - 🔗 {task.issueUrl.replace(/^https?:\/\//, "").slice(0, 60)} - - )} +
- {/* Detail */} - {task.detail ? ( -

- {task.detail} -

- ) : ( -

No details

- )} + {/* Issue URL */} + {task.issueUrl && ( + + 🔗 {task.issueUrl.replace(/^https?:\/\//, "").slice(0, 60)} + + )} - {/* Follow up */} - {task.followUp && ( -
-

- Latest Update -

+ {/* Detail */} + {task.detail ? (

- {task.followUp} + {task.detail}

-
- )} + ) : ( +

+ No details +

+ )} - {/* Linked notebook pages */} - {(linkedPages.length > 0 || !editing) && ( -
-
-

- Notebook Pages + {/* Follow up */} + {task.followUp && ( +

+

+ Latest Update +

+

+ {task.followUp}

-
- {linkedPages.map((lp) => ( -
- - 📄 {lp.pageTitle} - - -
- ))} - {linkedPages.length === 0 && !showPagePicker && ( -

- No linked pages -

- )} - {showPagePicker && ( -
- {availablePages - .filter( - (p) => - !linkedPages.some((lp) => lp.notebookPageId === p.id), - ) - .map((p) => ( - - ))} + {showPagePicker ? "Cancel" : "+ Link Page"} +
- )} -
- )} - - {/* Dependencies, scheduling & chaining */} - {(task.mustBeDoneAfterTaskId || - task.onOrAfterAt || - task.nextTaskId) && ( -
- {task.mustBeDoneAfterTaskId && ( -

- - Blocked by:{" "} - - {task.mustBeDoneAfterTaskId.slice(0, 8)} - -

- )} - {task.onOrAfterAt && ( -

- - Not before:{" "} - - {new Date(task.onOrAfterAt).toLocaleString()} - -

- )} - {task.nextTaskId && ( -

- - Next task:{" "} - - {task.nextTaskId.slice(0, 8)} - - {task.nextTaskAssigneeUserId && ( - - {" "} - → assigned to{" "} - {task.nextTaskAssigneeUserId} - - )} -

- )} -
- )} - -
{/* end scrollable */} - - {/* Pinned footer — status buttons + edit always visible */} -
- {/* Status actions */} -
- {ALL_STATUSES.filter((s) => s !== task.status).map((s) => { - const sc = STATUS_CONFIG[s]; - if (!sc) return null; - const Icon = sc.icon; - return ( - - ); - })} -
- - {/* Project leads */} - {project && - (project.projectLeadUserId || project.developerLeadUserId) && ( -
- {project.projectLeadUserId && ( - - Lead:{" "} - {project.projectLeadUserId} - + {linkedPages.map((lp) => ( +
+ + 📄 {lp.pageTitle} + + +
+ ))} + {linkedPages.length === 0 && !showPagePicker && ( +

+ No linked pages +

)} - {project.developerLeadUserId && ( - - Dev:{" "} - {project.developerLeadUserId} - + {showPagePicker && ( +
+ {availablePages + .filter( + (p) => + !linkedPages.some( + (lp) => lp.notebookPageId === p.id, + ), + ) + .map((p) => ( + + ))} +
)}
)} - {/* Timestamps */} -
-

Created: {new Date(task.createdAt).toLocaleString()}

-

Updated: {new Date(task.updatedAt).toLocaleString()}

- {task.completedAt && ( -

Completed: {new Date(task.completedAt).toLocaleString()}

- )} -
- - {/* Project links */} - {project && - (project.websiteUrl || - project.onedevUrl || - project.githubUrl) && ( -
- {project.websiteUrl && ( - - - + {/* Dependencies, scheduling & chaining */} + {(task.mustBeDoneAfterTaskId || + task.onOrAfterAt || + task.nextTaskId) && ( +
+ {task.mustBeDoneAfterTaskId && ( +

+ + Blocked by:{" "} + + {task.mustBeDoneAfterTaskId.slice(0, 8)} + +

)} - {project.onedevUrl && ( - - - + {task.onOrAfterAt && ( +

+ + Not before:{" "} + + {new Date(task.onOrAfterAt).toLocaleString()} + +

)} - {project.githubUrl && ( - - - + {task.nextTaskId && ( +

+ + Next task:{" "} + + {task.nextTaskId.slice(0, 8)} + + {task.nextTaskAssigneeUserId && ( + + {" "} + → assigned to{" "} + {task.nextTaskAssigneeUserId} + + )} +

)}
)} +
+ {/* end scrollable */} - {/* Edit button */} -
- + {/* Pinned footer — status buttons + edit always visible */} +
+ {/* Status actions */} +
+ {ALL_STATUSES.filter((s) => s !== task.status).map((s) => { + const sc = STATUS_CONFIG[s]; + if (!sc) return null; + const Icon = sc.icon; + return ( + + ); + })} +
+ + {/* Project leads */} + {project && + (project.projectLeadUserId || project.developerLeadUserId) && ( +
+ {project.projectLeadUserId && ( + + Lead:{" "} + {project.projectLeadUserId} + + )} + {project.developerLeadUserId && ( + + Dev:{" "} + {project.developerLeadUserId} + + )} +
+ )} + + {/* Timestamps */} +
+

Created: {new Date(task.createdAt).toLocaleString()}

+

Updated: {new Date(task.updatedAt).toLocaleString()}

+ {task.completedAt && ( +

+ Completed: {new Date(task.completedAt).toLocaleString()} +

+ )} +
+ + {/* Project links */} + {project && + (project.websiteUrl || + project.onedevUrl || + project.githubUrl) && ( +
+ {project.websiteUrl && ( + + + + )} + {project.onedevUrl && ( + + + + )} + {project.githubUrl && ( + + + + )} +
+ )} + + {/* Edit button */} +
+ +
-
{/* end pinned footer */} + {/* end pinned footer */}
)} @@ -1585,9 +1612,7 @@ function CreateTaskDialog({ const selectedProject = projects.find((p) => p.id === projectId); const allowedUsers = selectedProject?.taggedUsers && selectedProject.taggedUsers.length > 0 - ? knownUsers.filter((u) => - selectedProject.taggedUsers!.includes(u), - ) + ? knownUsers.filter((u) => selectedProject.taggedUsers!.includes(u)) : knownUsers; const [mustBeDoneAfter, setMustBeDoneAfter] = useState(""); const [onOrAfter, setOnOrAfter] = useState(""); From 314c3ad8814be699b318167d0f438148427abd8e Mon Sep 17 00:00:00 2001 From: Domingo Date: Mon, 23 Feb 2026 22:17:51 -0600 Subject: [PATCH 41/41] fix(auth,migrate): address Codex findings (users backfill, optional team_user grants) --- drizzle/0007_add_swarm_project_visibility.sql | 4 +- src/lib/auth.ts | 103 ++++++++++++++++++ src/lib/migrate.ts | 13 +++ 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/drizzle/0007_add_swarm_project_visibility.sql b/drizzle/0007_add_swarm_project_visibility.sql index 2db065b..e20ba14 100644 --- a/drizzle/0007_add_swarm_project_visibility.sql +++ b/drizzle/0007_add_swarm_project_visibility.sql @@ -3,5 +3,5 @@ ALTER TABLE swarm_projects ADD COLUMN IF NOT EXISTS tagged_users jsonb; --- GRANTs for Docker container user -GRANT ALL ON swarm_projects TO team_user; +-- NOTE: GRANTs are applied at runtime (see src/lib/migrate.ts) to avoid failing +-- migrations on databases where the team_user role does not exist. diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 845303d..f6ae3ac 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -36,6 +36,41 @@ async function loadUsersFromDb() { } } +/** + * Backfill missing users rows from active mailbox tokens. + * Important for upgrades: tokens may exist from before the users table was introduced. + */ +async function backfillUsersFromTokens() { + try { + const tokenIdentities = await db + .selectDistinct({ identity: mailboxTokens.identity }) + .from(mailboxTokens) + .where(isNull(mailboxTokens.revokedAt)); + + if (tokenIdentities.length === 0) return; + + await db + .insert(users) + .values( + tokenIdentities.map((t) => ({ + id: t.identity, + displayName: t.identity, + isAdmin: false, + isAgent: true, + })), + ) + .onConflictDoNothing(); + + for (const t of tokenIdentities) validMailboxes.add(t.identity); + + console.log( + `[auth] Backfilled users from tokens (checked ${tokenIdentities.length})`, + ); + } catch (err) { + console.error("[auth] Failed to backfill users from tokens:", err); + } +} + /** * Ensure the superuser's users row exists and is marked as admin. * Called at startup when SUPERUSER_NAME is configured. @@ -115,6 +150,69 @@ async function authenticateFromDb(token: string): Promise { .limit(1); if (!row) { + // Upgrade safety: token may exist from before the users table was populated. + // Try to backfill a users row for this token's identity and retry once. + const [tokenRow] = await db + .select({ identity: mailboxTokens.identity, id: mailboxTokens.id }) + .from(mailboxTokens) + .where( + and( + eq(mailboxTokens.token, token), + isNull(mailboxTokens.revokedAt), + or( + isNull(mailboxTokens.expiresAt), + gt(mailboxTokens.expiresAt, new Date()), + ), + ), + ) + .limit(1); + + if (tokenRow) { + await db + .insert(users) + .values({ + id: tokenRow.identity, + displayName: tokenRow.identity, + isAdmin: false, + isAgent: true, + }) + .onConflictDoNothing(); + + // Retry the original join query once + const [retryRow] = await db + .select({ + id: mailboxTokens.id, + identity: mailboxTokens.identity, + expiresAt: mailboxTokens.expiresAt, + isAdmin: users.isAdmin, + }) + .from(mailboxTokens) + .innerJoin(users, eq(mailboxTokens.identity, users.id)) + .where( + and( + eq(mailboxTokens.token, token), + isNull(mailboxTokens.revokedAt), + isNull(users.archivedAt), + or( + isNull(mailboxTokens.expiresAt), + gt(mailboxTokens.expiresAt, new Date()), + ), + ), + ) + .limit(1); + + if (retryRow) { + validMailboxes.add(retryRow.identity); + const ctx: AuthContext = { + identity: retryRow.identity, + isAdmin: retryRow.isAdmin, + source: "db", + }; + dbCache.set(token, { ctx, expires: Date.now() + DB_CACHE_TTL }); + return ctx; + } + } + dbCache.set(token, { ctx: null, expires: Date.now() + DB_CACHE_TTL }); return null; } @@ -203,6 +301,11 @@ export function initAuth() { loadUsersFromDb().catch((err) => console.error("[auth] Failed to load users from DB:", err), ); + + // Backfill missing users rows from mailbox tokens (upgrade safety) + backfillUsersFromTokens().catch((err) => + console.error("[auth] Failed to backfill users from tokens:", err), + ); } /** Clear the DB token cache (e.g., after creating/revoking tokens or changing user admin status) */ diff --git a/src/lib/migrate.ts b/src/lib/migrate.ts index 2758ca2..b7e47af 100644 --- a/src/lib/migrate.ts +++ b/src/lib/migrate.ts @@ -70,6 +70,19 @@ async function runMigrations() { ran++; } + // Apply optional grants for the Docker container role (if it exists) + // This avoids migrations failing in environments that don't have team_user. + try { + const role = await sql<{ exists: boolean }[]>` + SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'team_user') as exists + `; + if (role[0]?.exists) { + await sql.unsafe("GRANT ALL ON swarm_projects TO team_user"); + } + } catch (err) { + console.warn("[migrate] Optional grants failed (non-fatal):", err); + } + if (ran === 0) { console.log( `[migrate] Up to date (${files.length} migration(s) already applied)`,
section --- .../docs/getting-started/quickstart.md | 85 +++++++++++----- server/routes/api/doctor.get.ts | 97 +++++++++++++++++-- src/components/setup-profile.tsx | 57 ++++++++++- src/routes/onboard.tsx | 82 +++++++--------- 4 files changed, 241 insertions(+), 80 deletions(-) diff --git a/docs/src/content/docs/getting-started/quickstart.md b/docs/src/content/docs/getting-started/quickstart.md index 6f65116..d97f22c 100644 --- a/docs/src/content/docs/getting-started/quickstart.md +++ b/docs/src/content/docs/getting-started/quickstart.md @@ -19,12 +19,11 @@ cd hive # Copy the example environment cp .env.example .env -# Edit .env — set your superuser credentials: +# Edit .env — set your superuser credentials at minimum: # SUPERUSER_TOKEN= # SUPERUSER_NAME= -# -# Optional but recommended: -# SUPERUSER_DISPLAY_NAME=Chris +# SUPERUSER_DISPLAY_NAME=Chris (optional) +# HIVE_BASE_URL=http://localhost:3000 # Start Hive (includes PostgreSQL) docker compose -f docker-compose.dev.yml up @@ -48,7 +47,34 @@ cp .env.example .env docker compose -f docker-compose.test.yml up ``` -## Option 3: From Source +## Option 3: Production with Traefik + TLS + +For production deployments behind Traefik with automatic TLS: + +```bash +cp .env.example .env +``` + +Set these additional variables in your `.env`: + +```bash +# Public URL (used in invite links, skill docs, and agent wake responses) +HIVE_BASE_URL=https://hive.yourdomain.com + +# Hostname for Traefik routing +HIVE_HOSTNAME=hive.yourdomain.com + +# TLS cert resolver (letsencrypt or step-ca) +HIVE_TLS_CERTRESOLVER=letsencrypt +``` + +Then start: + +```bash +docker compose up -d +``` + +## Option 4: From Source If you prefer to run directly with Bun: @@ -70,14 +96,12 @@ cp .env.example .env # Edit .env — at minimum set: # PGHOST, PGUSER, PGPASSWORD, PGDATABASE_TEAM # SUPERUSER_TOKEN, SUPERUSER_NAME +# HIVE_BASE_URL=http://localhost:3000 # Install dependencies bun install -# Run migrations -bun run db:migrate - -# Start the dev server +# Start the dev server (migrations run automatically on startup) bun run dev ``` @@ -87,9 +111,9 @@ Hive will be available at `http://localhost:3000`. When you open Hive in your browser for the first time: -1. **Enter your Hive key** — this is the value of `SUPERUSER_TOKEN` from your `.env` -2. **Set your display name** — you'll be prompted to enter a display name before reaching the main interface -3. **You're in** — start inviting teammates via the Admin panel (`/admin → Invites`) +1. **Enter your Hive key** — this is the value of `SUPERUSER_TOKEN` from your `.env`. The login screen will remind you of this. +2. **Set your display name** — you'll be prompted to enter a display name before reaching the main interface. +3. **You're in** — you'll land in the admin panel with a checklist of next steps. ## First Steps @@ -103,13 +127,14 @@ curl -X POST http://localhost:3000/api/auth/verify \ ``` You should get back: + ```json { "identity": "chris", "isAdmin": true } ``` -### 2. Invite a Teammate +### 2. Invite a Teammate or Agent -Create an invite link to share with a teammate or agent: +Create an invite link from the **Admin → Auth** tab in the UI, or via the API: ```bash curl -X POST http://localhost:3000/api/auth/invites \ @@ -118,22 +143,32 @@ curl -X POST http://localhost:3000/api/auth/invites \ -d '{"maxUses": 1}' ``` -Share the returned `code` — they'll visit `/onboard?code=` to register. +Share the returned `code` — teammates visit `/onboard?code=` to register. Agents store the returned token in their `HIVE_TOKEN` environment variable. -### 3. Explore the Web UI +### 3. Check Your Instance Health + +```bash +curl http://localhost:3000/api/doctor \ + -H "Authorization: Bearer YOUR_SUPERUSER_TOKEN" +``` + +This runs diagnostics — database connectivity, migration status, config completeness. Fix any issues flagged before going further. + +### 4. Explore the Web UI Open `http://localhost:3000` in your browser. You'll see: -- **Messages** — Your inbox -- **Swarm** — Task management +- **Inbox** — Direct messages sent to you +- **Buzz** — Real-time event feed (CI, deploys, custom alerts) +- **Swarm** — Task board for agent and team work - **Notebook** — Collaborative documents -- **Buzz** — Event broadcasts - **Directory** — Shared links -- **Admin** — User and token management (at `/admin`) +- **Presence** — Who's online + team chat +- **Admin** — Manage agents, invites, and settings -### 4. Read the Skill Docs +### 5. Read the Skill Docs -Hive provides machine-readable documentation for agents: +Hive is self-documenting. Agents can discover the full API at runtime: ```bash curl http://localhost:3000/api/skill @@ -176,7 +211,11 @@ GRANT ALL ON DATABASE hive TO your_user; ### Can't log in -Make sure `SUPERUSER_TOKEN` in your `.env` matches exactly what you're entering in the UI. There are no default credentials — you set the token. +Make sure `SUPERUSER_TOKEN` in your `.env` matches exactly what you're entering in the UI. There are no default credentials — you set the token. Check that you haven't left it as `change-me-to-a-long-random-secret`. + +### Migrations didn't run + +Check `/api/doctor` for diagnostics. If the `_hive_migrations` tracking table is empty but tables exist, it may indicate migrations ran via `db:push` without being tracked — see the [migrations reference](/reference/migrations/). --- diff --git a/server/routes/api/doctor.get.ts b/server/routes/api/doctor.get.ts index 9ed1350..80ae664 100644 --- a/server/routes/api/doctor.get.ts +++ b/server/routes/api/doctor.get.ts @@ -53,24 +53,52 @@ export default defineEventHandler(async (event) => { probes.push( await runProbe("env", "Environment", async () => { const warnings: string[] = []; + const errors: string[] = []; + const required = ["PGHOST", "PGUSER", "PGPASSWORD"]; const missing = required.filter( (k) => !process.env[k] && !process.env[`HIVE_${k}`], ); if (missing.length > 0) { - return { - status: "warn", - summary: `Missing env vars: ${missing.join(", ")}`, - }; + errors.push(`Missing required env vars: ${missing.join(", ")}`); + } + + // Superuser config + if (!process.env.SUPERUSER_NAME) { + errors.push("SUPERUSER_NAME is not set — admin access unavailable"); } - // Check for required superuser config - if (!process.env.SUPERUSER_TOKEN || !process.env.SUPERUSER_NAME) { + const token = process.env.SUPERUSER_TOKEN || ""; + if (!token) { + errors.push("SUPERUSER_TOKEN is not set — admin access unavailable"); + } else if (token === "change-me-to-a-long-random-secret") { + errors.push( + "SUPERUSER_TOKEN is still the default placeholder — change it to a long random secret before going further", + ); + } else if (token.length < 24) { + warnings.push( + "SUPERUSER_TOKEN is short — use a token of at least 32 characters for security", + ); + } + + // HIVE_BASE_URL + const baseUrl = process.env.HIVE_BASE_URL || ""; + if (!baseUrl) { warnings.push( - "SUPERUSER_TOKEN and SUPERUSER_NAME are required for admin access", + "HIVE_BASE_URL is not set — invite links and skill docs will use localhost; set this to your public URL for production", ); + } else if (baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1")) { + if (process.env.NODE_ENV === "production") { + warnings.push( + `HIVE_BASE_URL is set to ${baseUrl} but NODE_ENV=production — update to your public URL`, + ); + } + } + + if (errors.length > 0) { + return { status: "fail", summary: errors[0], details: [...errors, ...warnings].join("\n") }; } if (warnings.length > 0) { - return { status: "warn", summary: warnings.join("; ") }; + return { status: "warn", summary: warnings[0], details: warnings.join("\n") }; } return { status: "pass", summary: "All required env vars present" }; }), @@ -136,6 +164,59 @@ export default defineEventHandler(async (event) => { }), ); + // Probe 5b: Users table + user count + probes.push( + await runProbe("users", "Users", async () => { + try { + const result = await db.execute(sql`SELECT COUNT(*) as count FROM users`); + const count = Number(result[0]?.count ?? 0); + if (count === 0) { + return { + status: "warn", + summary: "No users found — create invites from /admin and have teammates register via /onboard?code=…", + }; + } + return { status: "pass", summary: `${count} user(s) registered` }; + } catch (err: any) { + if (err.message?.includes("does not exist")) { + return { + status: "fail", + summary: "users table missing — migrations may not have run. Check /api/health and server logs.", + }; + } + return { status: "fail", summary: `Users check failed: ${err.message}` }; + } + }), + ); + + // Probe 5c: Migration tracking + probes.push( + await runProbe("migrations", "Migrations", async () => { + try { + const result = await db.execute( + sql`SELECT COUNT(*) as count FROM _hive_migrations`, + ); + const appliedCount = Number(result[0]?.count ?? 0); + if (appliedCount === 0) { + return { + status: "warn", + summary: + "Migration tracking table is empty — migrations may have run via db:push without being tracked. See /reference/migrations for backfill instructions.", + }; + } + return { + status: "pass", + summary: `${appliedCount} migration(s) tracked as applied`, + }; + } catch { + return { + status: "warn", + summary: "_hive_migrations table not found — migrations have not run yet", + }; + } + }), + ); + // Probe 6: Webhooks probes.push( await runProbe("webhooks", "Webhooks", async () => { diff --git a/src/components/setup-profile.tsx b/src/components/setup-profile.tsx index 469ec9c..1c9fb2d 100644 --- a/src/components/setup-profile.tsx +++ b/src/components/setup-profile.tsx @@ -1,4 +1,4 @@ -import { Sparkles } from "lucide-react"; +import { CheckCircle, Sparkles } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -22,6 +22,7 @@ export function SetupProfile({ const [name, setName] = useState(currentDisplayName); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); + const [done, setDone] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -49,7 +50,7 @@ export function SetupProfile({ // Mark setup as done so we don't show this again localStorage.setItem("hive-setup-complete", "1"); - onComplete(); + setDone(true); } catch { setError("Network error — try again"); } finally { @@ -57,6 +58,58 @@ export function SetupProfile({ } }; + if (done) { + return ( +
+ + +
+ +

You're in, {name}!

+
+

+ Here's what to do next to get your team connected: +

+
    +
  1. + 1. + + Create invites for your agents and teammates + — go to Admin → Auth and generate invite codes. + Each person visits /onboard?code=… to register. + +
  2. +
  3. + 2. + + Set HIVE_BASE_URL in your .env to your + public URL if you're not running locally — invite links and agent wake URLs depend on it. + +
  4. +
  5. + 3. + + Set up webhooks for real-time agent notifications + — agents register their webhook URL via POST /api/auth/webhook. + +
  6. +
  7. + 4. + + Check diagnostics at /api/doctor to confirm + everything is configured correctly. + +
  8. +
+ +
+
+
+ ); + } + return (
diff --git a/src/routes/onboard.tsx b/src/routes/onboard.tsx index aa6ade4..68f0b0f 100644 --- a/src/routes/onboard.tsx +++ b/src/routes/onboard.tsx @@ -207,61 +207,49 @@ curl -X POST \\
-
-

- ⚠️ Next Steps -

-

- 1. Tell your human operator to add this to{" "} - - ~/.openclaw/.env - - : -

- - HIVE_TOKEN={result.token} - -

- 2. Patch your gateway config (no secrets - needed — it reads from the env var): -

-
-                    {`{
-  "hooks": {
-    "enabled": true,
-    "token": "\${HIVE_TOKEN}",
-    "mappings": [{
-      "match": { "path": "/hooks/agent" },
-      "action": "agent",
-      "wakeMode": "now"
-    }]
-  }
-}`}
-                  
-

- 3. Restart the gateway, then register your - webhook URL — see{" "} - +

+

📖 Getting Started

+

+ Read the full{" "} + onboarding guide {" "} - Section 4. + — it covers how to configure your token, register a webhook for real-time delivery, and start using the Wake API.

-
- -

- Read the full API docs at{" "} - + Full API reference:{" "} + /api/skill

+ +
+

Next Steps

+
    +
  1. 1. Store your token securely in your agent's environment as HIVE_TOKEN
  2. +
  3. 2. Register a webhook so Hive can push messages to you in real time: POST /api/auth/webhook
  4. +
  5. 3. Start polling GET /api/wake to receive your prioritized action queue
  6. +
+
+ +
+ + Using with OpenClaw? (expand for setup steps) + +
+

+ 1. Add to ~/.openclaw/.env: +

+ + HIVE_TOKEN={result.token} + +

+ 2. Patch your gateway config to add a webhook hook, then restart the gateway and register your webhook URL — see the{" "} + onboarding guide Section 4. +

+
+
)} From 69a3410c61183035bb92b3f138ab3e0eab8f3ca1 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 15:21:19 -0600 Subject: [PATCH 23/41] fix: add missing migration for notebook_pages, directory_entries, content_project_tags, attachments These 4 tables existed in the schema and on production but had no migration file, meaning fresh installs would be missing them entirely. Discovered during onboarding test. --- ...006_add_notebook_directory_attachments.sql | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 drizzle/0006_add_notebook_directory_attachments.sql diff --git a/drizzle/0006_add_notebook_directory_attachments.sql b/drizzle/0006_add_notebook_directory_attachments.sql new file mode 100644 index 0000000..c6a34c0 --- /dev/null +++ b/drizzle/0006_add_notebook_directory_attachments.sql @@ -0,0 +1,58 @@ +CREATE TABLE "notebook_pages" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "title" varchar(255) NOT NULL, + "content" text DEFAULT '' NOT NULL, + "created_by" varchar(50) NOT NULL, + "tagged_users" jsonb, + "tags" jsonb DEFAULT '[]'::jsonb, + "expires_at" timestamp with time zone, + "review_at" timestamp with time zone, + "locked" boolean DEFAULT false NOT NULL, + "locked_by" varchar(50), + "archived_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "directory_entries" ( + "id" bigserial PRIMARY KEY NOT NULL, + "title" varchar(255) NOT NULL, + "url" text NOT NULL, + "description" text, + "created_by" varchar(50) NOT NULL, + "tagged_users" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "content_project_tags" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "content_type" varchar(20) NOT NULL, + "content_id" text NOT NULL, + "tagged_by" varchar(50) NOT NULL, + "tagged_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "attachments" ( + "id" text PRIMARY KEY NOT NULL, + "entity_type" varchar(20) NOT NULL, + "entity_id" text NOT NULL, + "filename" text NOT NULL, + "original_name" text NOT NULL, + "mime_type" varchar(100) NOT NULL, + "size" integer NOT NULL, + "created_by" varchar(50) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "content_project_tags" ADD CONSTRAINT "content_project_tags_project_id_swarm_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."swarm_projects"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "idx_notebook_created_at" ON "notebook_pages" USING btree ("created_at"); +--> statement-breakpoint +CREATE INDEX "idx_directory_created_at" ON "directory_entries" USING btree ("created_at"); +--> statement-breakpoint +CREATE INDEX "idx_content_project_tags_project" ON "content_project_tags" USING btree ("project_id"); +--> statement-breakpoint +CREATE INDEX "idx_content_project_tags_content" ON "content_project_tags" USING btree ("content_type","content_id"); +--> statement-breakpoint +CREATE INDEX "idx_attachments_entity" ON "attachments" USING btree ("entity_type","entity_id"); From a6476736b8f0c3cc3a58f14b3217937d0753c080 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 15:23:39 -0600 Subject: [PATCH 24/41] fix: make migration 0006 fully idempotent (IF NOT EXISTS everywhere) Fixes failure on production where tables already existed. Fresh installs will create all 4 tables; existing installs skip cleanly. --- ...006_add_notebook_directory_attachments.sql | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/drizzle/0006_add_notebook_directory_attachments.sql b/drizzle/0006_add_notebook_directory_attachments.sql index c6a34c0..eaa434b 100644 --- a/drizzle/0006_add_notebook_directory_attachments.sql +++ b/drizzle/0006_add_notebook_directory_attachments.sql @@ -1,4 +1,4 @@ -CREATE TABLE "notebook_pages" ( +CREATE TABLE IF NOT EXISTS "notebook_pages" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "title" varchar(255) NOT NULL, "content" text DEFAULT '' NOT NULL, @@ -14,7 +14,7 @@ CREATE TABLE "notebook_pages" ( "updated_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint -CREATE TABLE "directory_entries" ( +CREATE TABLE IF NOT EXISTS "directory_entries" ( "id" bigserial PRIMARY KEY NOT NULL, "title" varchar(255) NOT NULL, "url" text NOT NULL, @@ -24,7 +24,7 @@ CREATE TABLE "directory_entries" ( "created_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint -CREATE TABLE "content_project_tags" ( +CREATE TABLE IF NOT EXISTS "content_project_tags" ( "id" text PRIMARY KEY NOT NULL, "project_id" text NOT NULL, "content_type" varchar(20) NOT NULL, @@ -33,7 +33,7 @@ CREATE TABLE "content_project_tags" ( "tagged_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint -CREATE TABLE "attachments" ( +CREATE TABLE IF NOT EXISTS "attachments" ( "id" text PRIMARY KEY NOT NULL, "entity_type" varchar(20) NOT NULL, "entity_id" text NOT NULL, @@ -45,14 +45,16 @@ CREATE TABLE "attachments" ( "created_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint +ALTER TABLE "content_project_tags" DROP CONSTRAINT IF EXISTS "content_project_tags_project_id_swarm_projects_id_fk"; +--> statement-breakpoint ALTER TABLE "content_project_tags" ADD CONSTRAINT "content_project_tags_project_id_swarm_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."swarm_projects"("id") ON DELETE cascade ON UPDATE no action; --> statement-breakpoint -CREATE INDEX "idx_notebook_created_at" ON "notebook_pages" USING btree ("created_at"); +CREATE INDEX IF NOT EXISTS "idx_notebook_created_at" ON "notebook_pages" USING btree ("created_at"); --> statement-breakpoint -CREATE INDEX "idx_directory_created_at" ON "directory_entries" USING btree ("created_at"); +CREATE INDEX IF NOT EXISTS "idx_directory_created_at" ON "directory_entries" USING btree ("created_at"); --> statement-breakpoint -CREATE INDEX "idx_content_project_tags_project" ON "content_project_tags" USING btree ("project_id"); +CREATE INDEX IF NOT EXISTS "idx_content_project_tags_project" ON "content_project_tags" USING btree ("project_id"); --> statement-breakpoint -CREATE INDEX "idx_content_project_tags_content" ON "content_project_tags" USING btree ("content_type","content_id"); +CREATE INDEX IF NOT EXISTS "idx_content_project_tags_content" ON "content_project_tags" USING btree ("content_type","content_id"); --> statement-breakpoint -CREATE INDEX "idx_attachments_entity" ON "attachments" USING btree ("entity_type","entity_id"); +CREATE INDEX IF NOT EXISTS "idx_attachments_entity" ON "attachments" USING btree ("entity_type","entity_id"); From 0d6477e9583c52fa380686ba7dc0d2ce64365a65 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 15:51:59 -0600 Subject: [PATCH 25/41] fix: swarm tasks ?status= (singular) now works as alias for ?statuses= The API was silently ignoring ?status=ready from the docs example, returning all tasks instead. Now supports both: - ?statuses=ready,in_progress (canonical, comma-separated) - ?status=ready (singular alias, backward compat) Also updated docs/features/swarm.md to show the correct ?statuses= parameter with examples for single + multi-status filtering. --- docs/src/content/docs/features/swarm.md | 13 ++++++++++++- server/routes/api/swarm/tasks/index.get.ts | 6 +++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/features/swarm.md b/docs/src/content/docs/features/swarm.md index 88ca544..8778182 100644 --- a/docs/src/content/docs/features/swarm.md +++ b/docs/src/content/docs/features/swarm.md @@ -236,8 +236,19 @@ curl -X POST "https://your-hive-instance.com/api/swarm/tasks" \ ### List Tasks by Status +Use `statuses` (plural) with a comma-separated list. A single `status` value works too. + ```bash -curl -X GET "https://your-hive-instance.com/api/swarm/tasks?status=ready&assigneeUserId=me" \ +# One status +curl -X GET "https://your-hive-instance.com/api/swarm/tasks?statuses=ready" \ + -H "Authorization: Bearer YOUR_TOKEN" + +# Multiple statuses (active queue — excludes complete/closed) +curl -X GET "https://your-hive-instance.com/api/swarm/tasks?statuses=queued,ready,in_progress,review,holding" \ + -H "Authorization: Bearer YOUR_TOKEN" + +# Filter by assignee too +curl -X GET "https://your-hive-instance.com/api/swarm/tasks?statuses=ready,in_progress&assignee=me" \ -H "Authorization: Bearer YOUR_TOKEN" ``` diff --git a/server/routes/api/swarm/tasks/index.get.ts b/server/routes/api/swarm/tasks/index.get.ts index 1291925..402da2c 100644 --- a/server/routes/api/swarm/tasks/index.get.ts +++ b/server/routes/api/swarm/tasks/index.get.ts @@ -12,10 +12,10 @@ export default defineEventHandler(async (event) => { } const query = getQuery(event); + // Support both `statuses=a,b,c` (canonical) and `status=a` (legacy/convenience alias) + const statusParam = (query.statuses as string) || (query.status as string) || ""; const tasks = await listTasks({ - statuses: query.statuses - ? ((query.statuses as string).split(",") as any[]) - : undefined, + statuses: statusParam ? (statusParam.split(",") as any[]) : undefined, assignee: query.assignee as string | undefined, projectId: query.projectId as string | undefined, includeCompleted: query.includeCompleted === "true", From 0f8dd079b096c0e1ff7358394e2673307528aa49 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 20:36:02 -0600 Subject: [PATCH 26/41] feat: support assignee=me as shorthand for authenticated user Agents and users can now use ?assignee=me to scope tasks to themselves without knowing their own identity string. Resolves part of the task-scoping privacy concern. --- server/routes/api/swarm/tasks/index.get.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/routes/api/swarm/tasks/index.get.ts b/server/routes/api/swarm/tasks/index.get.ts index 402da2c..e12bf21 100644 --- a/server/routes/api/swarm/tasks/index.get.ts +++ b/server/routes/api/swarm/tasks/index.get.ts @@ -14,9 +14,13 @@ export default defineEventHandler(async (event) => { const query = getQuery(event); // Support both `statuses=a,b,c` (canonical) and `status=a` (legacy/convenience alias) const statusParam = (query.statuses as string) || (query.status as string) || ""; + // Support `assignee=me` as a shorthand for the authenticated user + const assigneeParam = query.assignee as string | undefined; + const resolvedAssignee = + assigneeParam === "me" ? auth.identity : assigneeParam; const tasks = await listTasks({ statuses: statusParam ? (statusParam.split(",") as any[]) : undefined, - assignee: query.assignee as string | undefined, + assignee: resolvedAssignee, projectId: query.projectId as string | undefined, includeCompleted: query.includeCompleted === "true", }); From 5f1315e4486c12154216a82ab1fd9e6f3193ee99 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 20:42:23 -0600 Subject: [PATCH 27/41] feat: project-level visibility for swarm (mirrors notebook pattern) Add optional tagged_users field to swarm_projects. When empty/null, the project (and all its tasks) is visible to all team members. When set, only the listed identities can see the project or its tasks. - schema: add taggedUsers to swarmProjects - migration: 0006_add_swarm_project_visibility.sql - listProjects: filter by visibility when identity is provided - listTasks: filter out tasks in projects caller can't see - GET /api/swarm/projects: pass auth.identity for visibility scoping - GET /api/swarm/tasks: pass auth.identity for visibility scoping - PATCH /api/swarm/projects/:id: accept taggedUsers to set visibility API usage: # Restrict a project to specific users PATCH /api/swarm/projects/:id { "taggedUsers": ["chris", "domingo"] } # Make it open again { "taggedUsers": [] } --- drizzle/0006_add_swarm_project_visibility.sql | 7 +++ .../routes/api/swarm/projects/[id].patch.ts | 7 +++ server/routes/api/swarm/projects/index.get.ts | 2 +- server/routes/api/swarm/tasks/index.get.ts | 1 + src/db/schema.ts | 5 ++ src/lib/swarm.ts | 46 +++++++++++++++++-- 6 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 drizzle/0006_add_swarm_project_visibility.sql diff --git a/drizzle/0006_add_swarm_project_visibility.sql b/drizzle/0006_add_swarm_project_visibility.sql new file mode 100644 index 0000000..2db065b --- /dev/null +++ b/drizzle/0006_add_swarm_project_visibility.sql @@ -0,0 +1,7 @@ +-- Migration: project-level visibility for swarm (mirrors notebook page pattern) +-- null or '[]' = open to all; non-empty array = restricted to listed identities + +ALTER TABLE swarm_projects ADD COLUMN IF NOT EXISTS tagged_users jsonb; + +-- GRANTs for Docker container user +GRANT ALL ON swarm_projects TO team_user; diff --git a/server/routes/api/swarm/projects/[id].patch.ts b/server/routes/api/swarm/projects/[id].patch.ts index e00b519..ef7e988 100644 --- a/server/routes/api/swarm/projects/[id].patch.ts +++ b/server/routes/api/swarm/projects/[id].patch.ts @@ -34,6 +34,13 @@ export default defineEventHandler(async (event) => { workHoursEnd: body.workHoursEnd, workHoursTimezone: body.workHoursTimezone, blockingMode: body.blockingMode, + // Visibility: null/[] = open to all; non-empty = restricted to listed identities + taggedUsers: + body.taggedUsers !== undefined + ? Array.isArray(body.taggedUsers) && body.taggedUsers.length > 0 + ? body.taggedUsers.map(String) + : null + : undefined, }); if (!project) { diff --git a/server/routes/api/swarm/projects/index.get.ts b/server/routes/api/swarm/projects/index.get.ts index ea2367c..9eca1cd 100644 --- a/server/routes/api/swarm/projects/index.get.ts +++ b/server/routes/api/swarm/projects/index.get.ts @@ -11,6 +11,6 @@ export default defineEventHandler(async (event) => { }); } - const projects = await listProjects(); + const projects = await listProjects(false, auth.identity); return { projects }; }); diff --git a/server/routes/api/swarm/tasks/index.get.ts b/server/routes/api/swarm/tasks/index.get.ts index e12bf21..68e93dd 100644 --- a/server/routes/api/swarm/tasks/index.get.ts +++ b/server/routes/api/swarm/tasks/index.get.ts @@ -23,6 +23,7 @@ export default defineEventHandler(async (event) => { assignee: resolvedAssignee, projectId: query.projectId as string | undefined, includeCompleted: query.includeCompleted === "true", + identity: auth.identity, }); return { tasks }; diff --git a/src/db/schema.ts b/src/db/schema.ts index 3f3ed48..a385a8f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -133,6 +133,11 @@ export const swarmProjects = pgTable("swarm_projects", { workHoursEnd: integer("work_hours_end"), workHoursTimezone: text("work_hours_timezone").default("America/Chicago"), blockingMode: boolean("blocking_mode").default(false), + /** Visibility control — mirrors notebook page pattern. + * null or [] → visible to all team members (default). + * Non-empty array → only listed identities can see this project and its tasks. + */ + taggedUsers: jsonb("tagged_users").$type(), archivedAt: timestamp("archived_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() diff --git a/src/lib/swarm.ts b/src/lib/swarm.ts index 85deae6..6aa9680 100644 --- a/src/lib/swarm.ts +++ b/src/lib/swarm.ts @@ -6,6 +6,8 @@ import { inArray, isNull, notInArray, + or, + sql, sql as rawSql, } from "drizzle-orm"; import { db } from "@/db"; @@ -69,14 +71,29 @@ export async function createProject(input: { export async function listProjects( includeArchived = false, + identity?: string, ): Promise { - if (includeArchived) { - return db.select().from(swarmProjects).orderBy(asc(swarmProjects.title)); + const conditions = []; + + if (!includeArchived) { + conditions.push(isNull(swarmProjects.archivedAt)); + } + + // Visibility: show project if tagged_users is null/empty (open), or includes this identity + if (identity) { + conditions.push( + sql`( + ${swarmProjects.taggedUsers} IS NULL + OR ${swarmProjects.taggedUsers} = '[]'::jsonb + OR ${swarmProjects.taggedUsers} @> ${sql`${JSON.stringify([identity])}::jsonb`} + )`, + ); } + return db .select() .from(swarmProjects) - .where(isNull(swarmProjects.archivedAt)) + .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(asc(swarmProjects.title)); } @@ -104,6 +121,7 @@ export async function updateProject( workHoursEnd: number | null; workHoursTimezone: string; blockingMode: boolean; + taggedUsers: string[] | null; }>, ): Promise { const [row] = await db @@ -186,6 +204,8 @@ export async function listTasks(opts?: { assignee?: string; projectId?: string; includeCompleted?: boolean; + /** Identity of the caller — used to apply project-level visibility filtering. */ + identity?: string; }): Promise { const conditions = []; @@ -203,6 +223,26 @@ export async function listTasks(opts?: { conditions.push(eq(swarmTasks.projectId, opts.projectId)); } + // Project-level visibility: exclude tasks from restricted projects the caller can't see. + // Mirrors the notebook page pattern: null/[] = open; non-empty = restricted to listed users. + if (opts?.identity) { + const identity = opts.identity; + conditions.push( + or( + isNull(swarmTasks.projectId), + sql`EXISTS ( + SELECT 1 FROM swarm_projects sp + WHERE sp.id = ${swarmTasks.projectId} + AND ( + sp.tagged_users IS NULL + OR sp.tagged_users = '[]'::jsonb + OR sp.tagged_users @> ${sql`${JSON.stringify([identity])}::jsonb`} + ) + )`, + )!, + ); + } + return db .select() .from(swarmTasks) From 456e31326aa6e625cdab7e2ded3a39a6c4985826 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 20:45:00 -0600 Subject: [PATCH 28/41] feat: filter assignee picker to project members when project is restricted When a project has taggedUsers set (visibility restriction), the task create and edit forms now only show users from that allowed list in the assignee and next-assignee dropdowns. Open projects still show all known users (no change in behavior). --- src/routes/swarm.tsx | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 5d87ba6..5badd3e 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -974,6 +974,15 @@ function TaskDetailDialog({ const [issueUrl, setIssueUrl] = useState(""); const [assignee, setAssignee] = useState(""); const [projectId, setProjectId] = useState(""); + // Filter assignee options to project members when project has visibility restrictions + const selectedProjectForEdit = projects.find((p) => p.id === projectId); + const allowedUsersForEdit = + selectedProjectForEdit?.taggedUsers && + selectedProjectForEdit.taggedUsers.length > 0 + ? knownUsers.filter((u) => + selectedProjectForEdit.taggedUsers!.includes(u), + ) + : knownUsers; const [mustBeDoneAfter, setMustBeDoneAfter] = useState(""); const [onOrAfter, setOnOrAfter] = useState(""); const [nextTaskId, setNextTaskId] = useState(""); @@ -1167,7 +1176,7 @@ function TaskDetailDialog({ onChange={(e) => setAssignee(e.target.value)} > - {knownUsers.map((u) => ( + {allowedUsersForEdit.map((u) => ( @@ -1225,7 +1234,7 @@ function TaskDetailDialog({ onChange={(e) => setNextTaskAssignee(e.target.value)} > - {knownUsers.map((u) => ( + {allowedUsersForEdit.map((u) => ( @@ -1546,6 +1555,14 @@ function CreateTaskDialog({ const [issueUrl, setIssueUrl] = useState(""); const [projectId, setProjectId] = useState(""); const [assignee, setAssignee] = useState(""); + // Filter assignee options to project members when project has visibility restrictions + const selectedProject = projects.find((p) => p.id === projectId); + const allowedUsers = + selectedProject?.taggedUsers && selectedProject.taggedUsers.length > 0 + ? knownUsers.filter((u) => + selectedProject.taggedUsers!.includes(u), + ) + : knownUsers; const [mustBeDoneAfter, setMustBeDoneAfter] = useState(""); const [onOrAfter, setOnOrAfter] = useState(""); const [nextTaskId, setNextTaskId] = useState(""); @@ -1639,7 +1656,7 @@ function CreateTaskDialog({ onChange={(e) => setAssignee(e.target.value)} > - {knownUsers.map((u) => ( + {allowedUsers.map((u) => ( @@ -1695,7 +1712,7 @@ function CreateTaskDialog({ onChange={(e) => setNextTaskAssignee(e.target.value)} > - {knownUsers.map((u) => ( + {allowedUsers.map((u) => ( From 80abb147adbf3ce915711474fc1a36097dbdfd59 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 20:45:57 -0600 Subject: [PATCH 29/41] fix: rename migration to 0007 to avoid collision with existing 0006 --- ...oject_visibility.sql => 0007_add_swarm_project_visibility.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename drizzle/{0006_add_swarm_project_visibility.sql => 0007_add_swarm_project_visibility.sql} (100%) diff --git a/drizzle/0006_add_swarm_project_visibility.sql b/drizzle/0007_add_swarm_project_visibility.sql similarity index 100% rename from drizzle/0006_add_swarm_project_visibility.sql rename to drizzle/0007_add_swarm_project_visibility.sql From 40842d3eac9b50efb86608e8ecf41a4fa307568c Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 20:47:20 -0600 Subject: [PATCH 30/41] fix: convert dynamic import to static in wake.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit await import('./base-url') inside getWakeItems() could fail in production bundling. All other files use static imports — make wake.ts consistent. --- src/lib/wake.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/wake.ts b/src/lib/wake.ts index 1b5c5f6..7773110 100644 --- a/src/lib/wake.ts +++ b/src/lib/wake.ts @@ -8,6 +8,7 @@ import { swarmProjects, swarmTasks, } from "@/db/schema"; +import { getBaseUrl } from "./base-url"; import { getPresence } from "./presence"; // ============================================================ @@ -359,7 +360,6 @@ export async function getWakeItems( } // --- Build per-source actions --- - const { getBaseUrl } = await import("./base-url"); const SKILL_BASE = `${getBaseUrl()}/api/skill`; const ACTION_MAP: Record< string, @@ -407,7 +407,7 @@ export async function getWakeItems( return { instructions: "When reading the actions within this document, you can use the skill_url to learn how to respond to the action.", - skill_url: `${getBaseUrl()}/api/skill`, + skill_url: SKILL_BASE, items, actions, summary, From 04a5eb091f09d6e4fd4bb9e1cccb599d6d0173e1 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 20:48:59 -0600 Subject: [PATCH 31/41] fix: rewrite task visibility filter to avoid broken correlated subquery Using a Drizzle column reference (swarmTasks.projectId) inside a raw sql template doesn't generate a column name - it serializes the column object, producing invalid SQL. Switch to a two-query approach: 1. Fetch visible project IDs from swarm_projects (with taggedUsers filter) 2. Filter swarmTasks where projectId IN (visible IDs) OR projectId IS NULL --- src/lib/swarm.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/lib/swarm.ts b/src/lib/swarm.ts index 6aa9680..5360c41 100644 --- a/src/lib/swarm.ts +++ b/src/lib/swarm.ts @@ -224,22 +224,22 @@ export async function listTasks(opts?: { } // Project-level visibility: exclude tasks from restricted projects the caller can't see. - // Mirrors the notebook page pattern: null/[] = open; non-empty = restricted to listed users. + // Fetch allowed project IDs first, then filter tasks. Two queries beats a broken correlated subquery. if (opts?.identity) { const identity = opts.identity; - conditions.push( - or( - isNull(swarmTasks.projectId), - sql`EXISTS ( - SELECT 1 FROM swarm_projects sp - WHERE sp.id = ${swarmTasks.projectId} - AND ( - sp.tagged_users IS NULL - OR sp.tagged_users = '[]'::jsonb - OR sp.tagged_users @> ${sql`${JSON.stringify([identity])}::jsonb`} - ) + const visibleProjects = await db + .select({ id: swarmProjects.id }) + .from(swarmProjects) + .where( + sql`( + ${swarmProjects.taggedUsers} IS NULL + OR ${swarmProjects.taggedUsers} = '[]'::jsonb + OR ${swarmProjects.taggedUsers} @> ${JSON.stringify([identity])}::jsonb )`, - )!, + ); + const visibleIds = visibleProjects.map((p) => p.id); + conditions.push( + or(isNull(swarmTasks.projectId), inArray(swarmTasks.projectId, visibleIds.length > 0 ? visibleIds : ["__none__"]))!, ); } From 1cdd2907a6e02ac0a1bfc62f30335be6c7feccae Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 20:55:39 -0600 Subject: [PATCH 32/41] fix: add in-context 'Show/Hide completed tasks' toggle to list view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolbar 'Show done' button is easy to miss. List view now shows a bottom-of-list link 'Show completed tasks' (and 'Hide completed tasks' when enabled) so the toggle is discoverable in context — exactly where you'd notice done items are missing. --- src/routes/swarm.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 5badd3e..ea37fd9 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -614,6 +614,8 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { projectMap={projectMap} onStatusChange={handleStatusChange} onTaskClick={(t) => setEditTask(t)} + showCompleted={showCompleted} + onToggleCompleted={() => setShowCompleted((v) => !v)} /> @@ -623,6 +625,8 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { projectMap={projectMap} onStatusChange={handleStatusChange} onTaskClick={(t) => setEditTask(t)} + showCompleted={showCompleted} + onToggleCompleted={() => setShowCompleted((v) => !v)} /> )} @@ -811,11 +815,15 @@ function ListView({ projectMap, onStatusChange, onTaskClick, + showCompleted, + onToggleCompleted, }: { groupedTasks: { status: string; tasks: SwarmTask[] }[]; projectMap: Map; onStatusChange: (id: string, status: string) => void; onTaskClick: (task: SwarmTask) => void; + showCompleted?: boolean; + onToggleCompleted?: () => void; }) { return (
@@ -943,6 +951,17 @@ function ListView({
); })} + + {/* In-context completed tasks toggle */} + {onToggleCompleted && ( + + )} ); } From 2b14074be793f8c7b709ceee59562550623611b3 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sun, 22 Feb 2026 22:01:25 -0600 Subject: [PATCH 33/41] =?UTF-8?q?fix:=20TypeScript=20quality=20pass=20?= =?UTF-8?q?=E2=80=94=20187=20errors=20=E2=86=92=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 — readBody type annotations: - All route handlers: readBody>(event) ?? {} (readBody doesn't work due to H3's InferEventInput generic) Round 2 — genuine bugs: - chat/channels/[id]/messages.post.ts: createdAt.toISOString() — Date was emitted as object over SSE instead of ISO string - broadcast.ts: capture forUser before filter closure to satisfy narrowing (params.forUser possibly undefined inside callback) - setup-profile.tsx: onComplete() was never called after save — parent was never notified that setup completed - tests/base-url.test.ts: vi missing from vitest imports Round 3 — unused symbols (real cleanup): - middleware/input-validation.ts: remove _MAX_TITLE/_MAX_BODY/_MAX_CONTENT (declared, never used — planned validation that wasn't implemented) - notebook/ws.ts: remove _SAVE_INTERVAL_MS (same) - src/routes/notebook.tsx: remove _saveTimer ref (same) - src/components/inbox.tsx: remove _updated assignments (API return values were captured but state was updated manually anyway) - src/components/markdown-editor.tsx: remove _isExternalUpdate ref Round 4 — type fixes: - attachments/[id].get.ts + avatars/[identity].get.ts: Readable.toWeb() for proper ReadableStream type (Node ReadStream → Web ReadableStream) - stream.get.ts: cast req to any before accessing .signal (Bun-specific) - swarm.tsx: add taggedUsers to local SwarmProject interface - swarm.tsx: wrap Lucide icons in (title prop not in LucideProps) - buzz.tsx: type bodyJson as Record|unknown[]|null - buzz.tsx: explicit BroadcastEvent[] annotation on evts - message-detail.tsx: useRef initial value to satisfy strict types Remaining (3 errors, pre-existing, not bugs): - user-select.tsx, directory.tsx, notebook.tsx: asChild={true} type conflict from Radix UI version mismatch — runtime works fine --- server/middleware/input-validation.ts | 3 --- server/routes/api/attachments/[id].get.ts | 3 ++- server/routes/api/auth/invites/index.post.ts | 2 +- server/routes/api/auth/register.post.ts | 2 +- server/routes/api/auth/setup-profile.post.ts | 2 +- server/routes/api/auth/webhook.post.ts | 2 +- server/routes/api/avatars/[identity].get.ts | 3 ++- server/routes/api/broadcast/webhooks.post.ts | 2 +- .../api/broadcast/webhooks/[id].patch.ts | 2 +- server/routes/api/chat/channels.post.ts | 2 +- .../api/chat/channels/[id]/messages.post.ts | 4 ++-- server/routes/api/directory/index.post.ts | 2 +- .../api/ingest/[appName]/[token].post.ts | 2 +- .../mailboxes/[recipient]/messages.post.ts | 2 +- .../mailboxes/me/messages/[id]/reply.post.ts | 2 +- .../api/mailboxes/me/messages/ack.post.ts | 2 +- server/routes/api/notebook/[id].patch.ts | 4 ++-- server/routes/api/notebook/index.post.ts | 2 +- server/routes/api/notebook/ws.ts | 1 - server/routes/api/stream.get.ts | 5 +++-- .../routes/api/swarm/projects/[id].patch.ts | 2 +- .../api/swarm/projects/[id]/tags.delete.ts | 2 +- .../api/swarm/projects/[id]/tags.post.ts | 2 +- .../routes/api/swarm/projects/index.post.ts | 2 +- .../api/swarm/recurring/[id]/index.patch.ts | 2 +- .../routes/api/swarm/recurring/index.post.ts | 2 +- .../api/swarm/tasks/[id]/index.patch.ts | 2 +- .../swarm/tasks/[id]/notebook-pages.delete.ts | 2 +- .../swarm/tasks/[id]/notebook-pages.post.ts | 2 +- .../api/swarm/tasks/[id]/status.patch.ts | 2 +- server/routes/api/swarm/tasks/index.post.ts | 2 +- src/components/inbox.tsx | 4 ++-- src/components/markdown-editor.tsx | 1 - src/components/message-detail.tsx | 2 +- src/components/setup-profile.tsx | 1 + src/lib/broadcast.ts | 3 ++- src/routes/buzz.tsx | 4 ++-- src/routes/notebook.tsx | 1 - src/routes/swarm.tsx | 22 +++++++++---------- tests/base-url.test.ts | 2 +- 40 files changed, 54 insertions(+), 57 deletions(-) diff --git a/server/middleware/input-validation.ts b/server/middleware/input-validation.ts index 69af9cd..b1e429c 100644 --- a/server/middleware/input-validation.ts +++ b/server/middleware/input-validation.ts @@ -1,8 +1,5 @@ import { createError, defineEventHandler, getMethod, getRequestPath } from "h3"; -const _MAX_TITLE = 255; -const _MAX_BODY = 10_000; -const _MAX_CONTENT = 100_000; // notebook pages can be longer const MAX_JSON_SIZE = 50_000; // 50KB general limit export default defineEventHandler(async (event) => { diff --git a/server/routes/api/attachments/[id].get.ts b/server/routes/api/attachments/[id].get.ts index 414bd65..3864801 100644 --- a/server/routes/api/attachments/[id].get.ts +++ b/server/routes/api/attachments/[id].get.ts @@ -1,4 +1,5 @@ import { createReadStream, existsSync } from "node:fs"; +import { Readable } from "node:stream"; import { join } from "node:path"; import { eq } from "drizzle-orm"; import { @@ -52,5 +53,5 @@ export default defineEventHandler(async (event) => { ); setResponseHeader(event, "Cache-Control", "private, max-age=3600"); - return sendStream(event, createReadStream(filePath)); + return sendStream(event, Readable.toWeb(createReadStream(filePath)) as ReadableStream); }); diff --git a/server/routes/api/auth/invites/index.post.ts b/server/routes/api/auth/invites/index.post.ts index 3bcec1f..993ca13 100644 --- a/server/routes/api/auth/invites/index.post.ts +++ b/server/routes/api/auth/invites/index.post.ts @@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const code = randomBytes(24).toString("hex"); const expiresIn = body?.expiresInHours ? Number(body.expiresInHours) : 72; diff --git a/server/routes/api/auth/register.post.ts b/server/routes/api/auth/register.post.ts index f9858c0..b4b22ae 100644 --- a/server/routes/api/auth/register.post.ts +++ b/server/routes/api/auth/register.post.ts @@ -6,7 +6,7 @@ import { invites, mailboxTokens, users } from "@/db/schema"; import { clearAuthCache, registerMailbox } from "@/lib/auth"; export default defineEventHandler(async (event) => { - const body = await readBody(event); + const body = await readBody>(event) ?? {}; if (!body?.code || !body?.identity) { return new Response( diff --git a/server/routes/api/auth/setup-profile.post.ts b/server/routes/api/auth/setup-profile.post.ts index 434a720..72048c6 100644 --- a/server/routes/api/auth/setup-profile.post.ts +++ b/server/routes/api/auth/setup-profile.post.ts @@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const displayName = body?.displayName?.trim(); if (!displayName || displayName.length < 1 || displayName.length > 100) { diff --git a/server/routes/api/auth/webhook.post.ts b/server/routes/api/auth/webhook.post.ts index da66a76..b3f7a82 100644 --- a/server/routes/api/auth/webhook.post.ts +++ b/server/routes/api/auth/webhook.post.ts @@ -22,7 +22,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const url = body?.url ?? null; const token = body?.token ?? null; diff --git a/server/routes/api/avatars/[identity].get.ts b/server/routes/api/avatars/[identity].get.ts index e086ae2..3c64baf 100644 --- a/server/routes/api/avatars/[identity].get.ts +++ b/server/routes/api/avatars/[identity].get.ts @@ -1,4 +1,5 @@ import { createReadStream, existsSync } from "node:fs"; +import { Readable } from "node:stream"; import { join } from "node:path"; import { defineEventHandler, @@ -38,7 +39,7 @@ export default defineEventHandler(async (event) => { mimeMap[ext] || "application/octet-stream", ); setResponseHeader(event, "Cache-Control", "public, max-age=3600"); - return sendStream(event, createReadStream(filePath)); + return sendStream(event, Readable.toWeb(createReadStream(filePath)) as ReadableStream); } } diff --git a/server/routes/api/broadcast/webhooks.post.ts b/server/routes/api/broadcast/webhooks.post.ts index 3767bb7..3116669 100644 --- a/server/routes/api/broadcast/webhooks.post.ts +++ b/server/routes/api/broadcast/webhooks.post.ts @@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; if (!body?.appName || !body?.title) { return new Response( JSON.stringify({ error: "appName and title are required" }), diff --git a/server/routes/api/broadcast/webhooks/[id].patch.ts b/server/routes/api/broadcast/webhooks/[id].patch.ts index 9841c49..2048cc4 100644 --- a/server/routes/api/broadcast/webhooks/[id].patch.ts +++ b/server/routes/api/broadcast/webhooks/[id].patch.ts @@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const updates: Record = {}; if (body.title !== undefined) updates.title = body.title; if (body.appName !== undefined) updates.appName = body.appName; diff --git a/server/routes/api/chat/channels.post.ts b/server/routes/api/chat/channels.post.ts index e84e958..81898fc 100644 --- a/server/routes/api/chat/channels.post.ts +++ b/server/routes/api/chat/channels.post.ts @@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; if (body?.type === "group") { if (!body.name || !body.members?.length) { diff --git a/server/routes/api/chat/channels/[id]/messages.post.ts b/server/routes/api/chat/channels/[id]/messages.post.ts index 9836feb..8a67d7c 100644 --- a/server/routes/api/chat/channels/[id]/messages.post.ts +++ b/server/routes/api/chat/channels/[id]/messages.post.ts @@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; if (!body?.body?.trim()) { return new Response(JSON.stringify({ error: "body required" }), { status: 400, @@ -52,7 +52,7 @@ export default defineEventHandler(async (event) => { id: message.id, sender: message.sender, body: message.body, - createdAt: message.createdAt, + createdAt: message.createdAt.toISOString(), }, }); diff --git a/server/routes/api/directory/index.post.ts b/server/routes/api/directory/index.post.ts index 11a5596..753460a 100644 --- a/server/routes/api/directory/index.post.ts +++ b/server/routes/api/directory/index.post.ts @@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const { title, url, description, taggedUsers } = body ?? {}; if (!title?.trim() || !url?.trim()) { diff --git a/server/routes/api/ingest/[appName]/[token].post.ts b/server/routes/api/ingest/[appName]/[token].post.ts index 904db11..a8eaaed 100644 --- a/server/routes/api/ingest/[appName]/[token].post.ts +++ b/server/routes/api/ingest/[appName]/[token].post.ts @@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => { let title = webhook.title; try { - const raw = await readBody(event); + const raw = await readBody>(event) ?? {}; if (contentType.includes("application/json")) { bodyJson = raw; if (raw?.title) title = raw.title; diff --git a/server/routes/api/mailboxes/[recipient]/messages.post.ts b/server/routes/api/mailboxes/[recipient]/messages.post.ts index 59a7e6b..30b753d 100644 --- a/server/routes/api/mailboxes/[recipient]/messages.post.ts +++ b/server/routes/api/mailboxes/[recipient]/messages.post.ts @@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => { ); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; if (!body?.title) { return new Response(JSON.stringify({ error: "title is required" }), { status: 400, diff --git a/server/routes/api/mailboxes/me/messages/[id]/reply.post.ts b/server/routes/api/mailboxes/me/messages/[id]/reply.post.ts index 2c88060..771f8b2 100644 --- a/server/routes/api/mailboxes/me/messages/[id]/reply.post.ts +++ b/server/routes/api/mailboxes/me/messages/[id]/reply.post.ts @@ -23,7 +23,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; if (!body?.body) { return new Response(JSON.stringify({ error: "body is required" }), { status: 400, diff --git a/server/routes/api/mailboxes/me/messages/ack.post.ts b/server/routes/api/mailboxes/me/messages/ack.post.ts index f906c38..9145c09 100644 --- a/server/routes/api/mailboxes/me/messages/ack.post.ts +++ b/server/routes/api/mailboxes/me/messages/ack.post.ts @@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => { updatePresence(auth.identity, "api"); - const body = await readBody(event); + const body = await readBody>(event) ?? {}; if (!body?.ids || !Array.isArray(body.ids)) { return new Response(JSON.stringify({ error: "ids array is required" }), { status: 400, diff --git a/server/routes/api/notebook/[id].patch.ts b/server/routes/api/notebook/[id].patch.ts index 1181a9c..2a884da 100644 --- a/server/routes/api/notebook/[id].patch.ts +++ b/server/routes/api/notebook/[id].patch.ts @@ -57,7 +57,7 @@ export default defineEventHandler(async (event) => { // Archived pages: only owner/admin can unarchive, no other edits allowed if (page.archivedAt) { - const body = await readBody(event); + const body = await readBody>(event) ?? {}; if (body?.archived === false && isOwnerOrAdmin) { const [restored] = await db .update(notebookPages) @@ -81,7 +81,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const { title, content, taggedUsers, tags, locked, expiresAt, reviewAt } = body ?? {}; diff --git a/server/routes/api/notebook/index.post.ts b/server/routes/api/notebook/index.post.ts index ee90e31..39aa8fe 100644 --- a/server/routes/api/notebook/index.post.ts +++ b/server/routes/api/notebook/index.post.ts @@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const { title, content, taggedUsers, tags, expiresAt, reviewAt } = body ?? {}; if (!title?.trim()) { diff --git a/server/routes/api/notebook/ws.ts b/server/routes/api/notebook/ws.ts index 96b6b94..5e8bbc6 100644 --- a/server/routes/api/notebook/ws.ts +++ b/server/routes/api/notebook/ws.ts @@ -22,7 +22,6 @@ interface DocEntry { const docs = new Map(); const SAVE_DEBOUNCE_MS = 5_000; -const _SAVE_INTERVAL_MS = 30_000; async function getOrCreateDoc(pageId: string): Promise { if (docs.has(pageId)) return docs.get(pageId)!; diff --git a/server/routes/api/stream.get.ts b/server/routes/api/stream.get.ts index a299264..154cd34 100644 --- a/server/routes/api/stream.get.ts +++ b/server/routes/api/stream.get.ts @@ -135,8 +135,9 @@ export default defineEventHandler(async (event) => { event.node.req.on("close", cleanup); } // Also use AbortSignal if available (Bun) - if (event.node?.req?.signal) { - (event.node.req as any).signal.addEventListener("abort", cleanup); + const nodeReq = event.node?.req as any; + if (nodeReq?.signal) { + nodeReq.signal.addEventListener("abort", cleanup); } }, }); diff --git a/server/routes/api/swarm/projects/[id].patch.ts b/server/routes/api/swarm/projects/[id].patch.ts index ef7e988..e2eea1d 100644 --- a/server/routes/api/swarm/projects/[id].patch.ts +++ b/server/routes/api/swarm/projects/[id].patch.ts @@ -19,7 +19,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const project = await updateProject(id, { title: body.title, description: body.description, diff --git a/server/routes/api/swarm/projects/[id]/tags.delete.ts b/server/routes/api/swarm/projects/[id]/tags.delete.ts index a520279..67f758e 100644 --- a/server/routes/api/swarm/projects/[id]/tags.delete.ts +++ b/server/routes/api/swarm/projects/[id]/tags.delete.ts @@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const { contentType, contentId } = body || {}; if (!contentType || !contentId) { diff --git a/server/routes/api/swarm/projects/[id]/tags.post.ts b/server/routes/api/swarm/projects/[id]/tags.post.ts index db9d16f..6c10b95 100644 --- a/server/routes/api/swarm/projects/[id]/tags.post.ts +++ b/server/routes/api/swarm/projects/[id]/tags.post.ts @@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const { contentType, contentId } = body || {}; const validTypes = [ diff --git a/server/routes/api/swarm/projects/index.post.ts b/server/routes/api/swarm/projects/index.post.ts index e550fe2..3daf0e4 100644 --- a/server/routes/api/swarm/projects/index.post.ts +++ b/server/routes/api/swarm/projects/index.post.ts @@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; if (!body?.title || !body?.color) { return new Response( JSON.stringify({ error: "title and color are required" }), diff --git a/server/routes/api/swarm/recurring/[id]/index.patch.ts b/server/routes/api/swarm/recurring/[id]/index.patch.ts index 338143e..807feff 100644 --- a/server/routes/api/swarm/recurring/[id]/index.patch.ts +++ b/server/routes/api/swarm/recurring/[id]/index.patch.ts @@ -19,7 +19,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const template = await updateRecurringTemplate(id, { projectId: body.projectId, title: body.title, diff --git a/server/routes/api/swarm/recurring/index.post.ts b/server/routes/api/swarm/recurring/index.post.ts index 14a3f7d..c95b42e 100644 --- a/server/routes/api/swarm/recurring/index.post.ts +++ b/server/routes/api/swarm/recurring/index.post.ts @@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; if (!body?.title || !body?.cronExpr) { return new Response( JSON.stringify({ error: "title and cronExpr are required" }), diff --git a/server/routes/api/swarm/tasks/[id]/index.patch.ts b/server/routes/api/swarm/tasks/[id]/index.patch.ts index 9f0b364..d257800 100644 --- a/server/routes/api/swarm/tasks/[id]/index.patch.ts +++ b/server/routes/api/swarm/tasks/[id]/index.patch.ts @@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const task = await updateTask(id, { projectId: body.projectId, title: body.title, diff --git a/server/routes/api/swarm/tasks/[id]/notebook-pages.delete.ts b/server/routes/api/swarm/tasks/[id]/notebook-pages.delete.ts index e56ffab..40931c5 100644 --- a/server/routes/api/swarm/tasks/[id]/notebook-pages.delete.ts +++ b/server/routes/api/swarm/tasks/[id]/notebook-pages.delete.ts @@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const { notebookPageId } = body ?? {}; if (!notebookPageId) { diff --git a/server/routes/api/swarm/tasks/[id]/notebook-pages.post.ts b/server/routes/api/swarm/tasks/[id]/notebook-pages.post.ts index 9fd61ba..181bdc5 100644 --- a/server/routes/api/swarm/tasks/[id]/notebook-pages.post.ts +++ b/server/routes/api/swarm/tasks/[id]/notebook-pages.post.ts @@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; const { notebookPageId } = body ?? {}; if (!notebookPageId) { diff --git a/server/routes/api/swarm/tasks/[id]/status.patch.ts b/server/routes/api/swarm/tasks/[id]/status.patch.ts index 4456c48..c26b75f 100644 --- a/server/routes/api/swarm/tasks/[id]/status.patch.ts +++ b/server/routes/api/swarm/tasks/[id]/status.patch.ts @@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; if (!body?.status) { return new Response(JSON.stringify({ error: "status required" }), { status: 400, diff --git a/server/routes/api/swarm/tasks/index.post.ts b/server/routes/api/swarm/tasks/index.post.ts index 40e1199..3bd46ad 100644 --- a/server/routes/api/swarm/tasks/index.post.ts +++ b/server/routes/api/swarm/tasks/index.post.ts @@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => { }); } - const body = await readBody(event); + const body = await readBody>(event) ?? {}; if (!body?.title) { return new Response(JSON.stringify({ error: "title is required" }), { status: 400, diff --git a/src/components/inbox.tsx b/src/components/inbox.tsx index dd4be41..99017fa 100644 --- a/src/components/inbox.tsx +++ b/src/components/inbox.tsx @@ -317,7 +317,7 @@ export function InboxView({ onLogout }: { onLogout: () => void }) { }} onTogglePending={async () => { if (selectedMessage.responseWaiting) { - const _updated = await api.clearPending(selectedMessage.id); + await api.clearPending(selectedMessage.id); setSelectedMessage({ ...selectedMessage, responseWaiting: false, @@ -325,7 +325,7 @@ export function InboxView({ onLogout }: { onLogout: () => void }) { waitingSince: null, }); } else { - const _updated = await api.markPending(selectedMessage.id); + await api.markPending(selectedMessage.id); setSelectedMessage({ ...selectedMessage, responseWaiting: true, diff --git a/src/components/markdown-editor.tsx b/src/components/markdown-editor.tsx index 1342268..1b1cc0c 100644 --- a/src/components/markdown-editor.tsx +++ b/src/components/markdown-editor.tsx @@ -105,7 +105,6 @@ export function MarkdownEditor({ const onReadonlyRef = useRef(onReadonlyChange); const ydocRef = useRef(null); const ytextRef = useRef(null); - const _isExternalUpdate = useRef(false); const initializedRef = useRef(false); const valueRef = useRef(value); valueRef.current = value; diff --git a/src/components/message-detail.tsx b/src/components/message-detail.tsx index 5ce9f93..12d6f69 100644 --- a/src/components/message-detail.tsx +++ b/src/components/message-detail.tsx @@ -43,7 +43,7 @@ export function MessageDetail({ const [replying, setReplying] = useState(false); const [showReply, setShowReply] = useState(false); const [copied, setCopied] = useState(false); - const autoReadRef = useRef>(); + const autoReadRef = useRef | undefined>(undefined); // Auto-read after 5 seconds of viewing useEffect(() => { diff --git a/src/components/setup-profile.tsx b/src/components/setup-profile.tsx index 1c9fb2d..984f505 100644 --- a/src/components/setup-profile.tsx +++ b/src/components/setup-profile.tsx @@ -51,6 +51,7 @@ export function SetupProfile({ // Mark setup as done so we don't show this again localStorage.setItem("hive-setup-complete", "1"); setDone(true); + onComplete(); } catch { setError("Network error — try again"); } finally { diff --git a/src/lib/broadcast.ts b/src/lib/broadcast.ts index a3b35ab..9c3377f 100644 --- a/src/lib/broadcast.ts +++ b/src/lib/broadcast.ts @@ -122,10 +122,11 @@ export async function listEvents(params?: { } if (params?.forUser) { + const forUser = params.forUser.toLowerCase(); rows = rows.filter((e) => { if (!e.forUsers) return true; const users = e.forUsers.split(",").map((u) => u.trim().toLowerCase()); - return users.includes(params.forUser?.toLowerCase()); + return users.includes(forUser); }); } diff --git a/src/routes/buzz.tsx b/src/routes/buzz.tsx index b9db9c7..e279a63 100644 --- a/src/routes/buzz.tsx +++ b/src/routes/buzz.tsx @@ -21,7 +21,7 @@ interface BroadcastEvent { receivedAt: string; contentType: string | null; bodyText: string | null; - bodyJson: unknown | null; + bodyJson: Record | unknown[] | null; } function timeAgo(date: string): string { @@ -109,7 +109,7 @@ function BuzzView({ onLogout }: { onLogout: () => void }) { setLoading(true); try { const result = await api.listBroadcastEvents(appFilter || undefined); - const evts = result.events || []; + const evts: BroadcastEvent[] = result.events || []; setEvents(evts); // Extract unique app names const uniqueApps = [ diff --git a/src/routes/notebook.tsx b/src/routes/notebook.tsx index 42c2992..60a67ef 100644 --- a/src/routes/notebook.tsx +++ b/src/routes/notebook.tsx @@ -400,7 +400,6 @@ function PageEditor({ const [isAdmin, setIsAdmin] = useState(false); const [viewers, setViewers] = useState([]); const [copied, setCopied] = useState<"idle" | "url" | "content">("idle"); - const _saveTimer = useRef | null>(null); const contentRef = useRef(content); const authToken = getMailboxKey(); diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index ea37fd9..793d834 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -79,6 +79,7 @@ interface SwarmProject { workHoursStart?: number | null; workHoursEnd?: number | null; workHoursTimezone?: string | null; + taggedUsers?: string[] | null; } const STATUS_CONFIG: Record< @@ -745,22 +746,19 @@ function TaskCard({
{task.mustBeDoneAfterTaskId && ( - + + + )} {task.onOrAfterAt && ( - + + + )} {task.nextTaskId && ( - + + + )} {task.assigneeUserId && ( diff --git a/tests/base-url.test.ts b/tests/base-url.test.ts index 83d8114..79ae0f1 100644 --- a/tests/base-url.test.ts +++ b/tests/base-url.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; describe("getBaseUrl", () => { const original = process.env.HIVE_BASE_URL; From 663d9e91a12a6f7c754f4b479b0f04637f1e4b6a Mon Sep 17 00:00:00 2001 From: Domingo Date: Mon, 23 Feb 2026 06:49:08 -0600 Subject: [PATCH 34/41] feat(swarm): always show done column with 12h default, expand by 1 month increments - Done column (complete/closed) always visible in kanban and list modes - Defaults to showing tasks completed in the past 12 hours - 'Show more items (N older)' button at bottom of each done section - Each click expands window by 1 additional month - Removed 'Show done / Hide done' toggle (no longer needed) - Applies to both board columns and list view sections --- src/routes/swarm.tsx | 119 +++++++++++++++++++++++++------------------ 1 file changed, 70 insertions(+), 49 deletions(-) diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 793d834..9fd6efa 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -174,7 +174,7 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { const [tasks, setTasks] = useState([]); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(false); - const [showCompleted, setShowCompleted] = useState(false); + const [completedMonthsToShow, setCompletedMonthsToShow] = useState(0); const [viewMode, setViewMode] = useState<"board" | "list">("board"); const [createOpen, setCreateOpen] = useState(false); const [createProjectOpen, setCreateProjectOpen] = useState(false); @@ -238,7 +238,7 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { setLoading(true); try { const [taskResult, projectResult] = await Promise.all([ - api.listTasks({ includeCompleted: showCompleted }), + api.listTasks({ includeCompleted: true }), api.listProjects(), ]); setTasks(taskResult.tasks || []); @@ -248,7 +248,7 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { } finally { setLoading(false); } - }, [showCompleted]); + }, []); useEffect(() => { fetchData(); @@ -310,19 +310,36 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { ...new Set(tasks.map((t) => t.assigneeUserId).filter(Boolean)), ] as string[]; - const visibleStatuses = ALL_STATUSES.filter( - (s) => showCompleted || (s !== "complete" && s !== "closed"), - ); + const visibleStatuses = ALL_STATUSES; + + // Compute cutoff for done tasks: default 12h, each "show more" adds 1 month + const completedCutoff = + completedMonthsToShow === 0 + ? new Date(Date.now() - 12 * 60 * 60 * 1000) + : (() => { + const d = new Date(); + d.setMonth(d.getMonth() - completedMonthsToShow); + return d; + })(); - const groupedTasks = visibleStatuses.map((status) => ({ - status, - tasks: filteredTasks + const groupedTasks = visibleStatuses.map((status) => { + const allStatusTasks = filteredTasks .filter((t) => t.status === status) .sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), - ), - })); + ); + + if (status === "complete" || status === "closed") { + const visibleTasks = allStatusTasks.filter((t) => { + const dateStr = t.completedAt || t.updatedAt; + return new Date(dateStr) >= completedCutoff; + }); + return { status, tasks: visibleTasks, hiddenCount: allStatusTasks.length - visibleTasks.length }; + } + + return { status, tasks: allStatusTasks, hiddenCount: 0 }; + }); const projectMap = new Map(projects.map((p) => [p.id, p])); @@ -429,15 +446,6 @@ function SwarmView({ onLogout }: { onLogout: () => void }) {
- - {/* View toggle */}
+ )} + {isDoneColumn && statusTasks.length === 0 && hiddenCount === 0 && ( +

No tasks

+ )}
); @@ -615,8 +636,8 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { projectMap={projectMap} onStatusChange={handleStatusChange} onTaskClick={(t) => setEditTask(t)} - showCompleted={showCompleted} - onToggleCompleted={() => setShowCompleted((v) => !v)} + completedMonthsToShow={completedMonthsToShow} + onShowMore={() => setCompletedMonthsToShow((v) => v + 1)} />
@@ -626,8 +647,8 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { projectMap={projectMap} onStatusChange={handleStatusChange} onTaskClick={(t) => setEditTask(t)} - showCompleted={showCompleted} - onToggleCompleted={() => setShowCompleted((v) => !v)} + completedMonthsToShow={completedMonthsToShow} + onShowMore={() => setCompletedMonthsToShow((v) => v + 1)} /> )} @@ -813,26 +834,23 @@ function ListView({ projectMap, onStatusChange, onTaskClick, - showCompleted, - onToggleCompleted, + completedMonthsToShow: _completedMonthsToShow, + onShowMore, }: { - groupedTasks: { status: string; tasks: SwarmTask[] }[]; + groupedTasks: { status: string; tasks: SwarmTask[]; hiddenCount: number }[]; projectMap: Map; onStatusChange: (id: string, status: string) => void; onTaskClick: (task: SwarmTask) => void; - showCompleted?: boolean; - onToggleCompleted?: () => void; + completedMonthsToShow: number; + onShowMore: () => void; }) { return (
- {groupedTasks.map(({ status, tasks: statusTasks }) => { + {groupedTasks.map(({ status, tasks: statusTasks, hiddenCount }) => { const config = STATUS_CONFIG[status]; if (!config) return null; - if ( - statusTasks.length === 0 && - (status === "complete" || status === "closed") - ) - return null; + const isDoneSection = status === "complete" || status === "closed"; + if (statusTasks.length === 0 && !isDoneSection) return null; const StatusIcon = config.icon; return ( @@ -846,7 +864,9 @@ function ListView({
{statusTasks.length === 0 ? ( -

No tasks

+

+ {isDoneSection ? "Nothing completed in this window" : "No tasks"} +

) : (
{statusTasks.map((task) => { @@ -946,20 +966,21 @@ function ListView({ })}
)} + + {/* Show more items button for done sections */} + {isDoneSection && hiddenCount > 0 && ( + + )}
); })} - - {/* In-context completed tasks toggle */} - {onToggleCompleted && ( - - )} ); } From 998910b5f11083432db207822af33be68c51f306 Mon Sep 17 00:00:00 2001 From: Domingo Date: Mon, 23 Feb 2026 06:59:12 -0600 Subject: [PATCH 35/41] feat(chat): has_activity flag for efficient wake polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add has_activity BOOLEAN to chat_members (default false) - Partial index on (identity) WHERE has_activity = true for fast wake queries - Set true for all non-sender members when a message is sent - Cleared (false) on markChannelRead — triggered by GET messages or POST /read - Wake endpoint now includes chat source: queries has_activity channels, fetches new messages since last_read_at, builds summary with sender/preview - WakeItem type extended with channelId, channelType, channelName fields - Action map includes chat entry pointing to /api/skill/chat --- drizzle/0008_add_chat_has_activity.sql | 10 ++++ src/db/schema.ts | 1 + src/lib/chat.ts | 16 +++++- src/lib/wake.ts | 80 +++++++++++++++++++++++++- 4 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 drizzle/0008_add_chat_has_activity.sql diff --git a/drizzle/0008_add_chat_has_activity.sql b/drizzle/0008_add_chat_has_activity.sql new file mode 100644 index 0000000..24d0b8e --- /dev/null +++ b/drizzle/0008_add_chat_has_activity.sql @@ -0,0 +1,10 @@ +-- Add has_activity flag to chat_members for efficient wake polling. +-- Set to true when a message is sent in the channel (for all members except sender). +-- Cleared when the member reads the channel (via API or UI auto-read). + +ALTER TABLE chat_members ADD COLUMN IF NOT EXISTS has_activity BOOLEAN NOT NULL DEFAULT false; + +-- Partial index: only index rows with activity, keeping the index tiny +CREATE INDEX IF NOT EXISTS idx_chat_members_activity + ON chat_members(identity) + WHERE has_activity = true; diff --git a/src/db/schema.ts b/src/db/schema.ts index a385a8f..01654ac 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -297,6 +297,7 @@ export const chatMembers = pgTable("chat_members", { .notNull(), lastReadAt: timestamp("last_read_at", { withTimezone: true }), archivedAt: timestamp("archived_at", { withTimezone: true }), // per-member soft delete + hasActivity: boolean("has_activity").notNull().default(false), // true when unread messages exist; cleared on read }); export const chatMessages = pgTable("chat_messages", { diff --git a/src/lib/chat.ts b/src/lib/chat.ts index 55a6270..0409493 100644 --- a/src/lib/chat.ts +++ b/src/lib/chat.ts @@ -186,14 +186,26 @@ export async function sendChatMessage( .insert(chatMessages) .values({ channelId, sender, body }) .returning(); + + // Flag all other members as having unread activity + await db + .update(chatMembers) + .set({ hasActivity: true }) + .where( + and( + eq(chatMembers.channelId, channelId), + rawSql`${chatMembers.identity} != ${sender}`, + ), + ); + return msg; } -/** Mark channel as read */ +/** Mark channel as read and clear activity flag */ export async function markChannelRead(channelId: string, identity: string) { await db .update(chatMembers) - .set({ lastReadAt: new Date() }) + .set({ lastReadAt: new Date(), hasActivity: false }) .where( and( eq(chatMembers.channelId, channelId), diff --git a/src/lib/wake.ts b/src/lib/wake.ts index 7773110..1813fd0 100644 --- a/src/lib/wake.ts +++ b/src/lib/wake.ts @@ -8,6 +8,7 @@ import { swarmProjects, swarmTasks, } from "@/db/schema"; +import { sql as rawSql } from "drizzle-orm"; import { getBaseUrl } from "./base-url"; import { getPresence } from "./presence"; @@ -16,7 +17,7 @@ import { getPresence } from "./presence"; // ============================================================ export interface WakeItem { - source: "message" | "message_pending" | "swarm" | "buzz" | "backup"; + source: "message" | "message_pending" | "swarm" | "buzz" | "backup" | "chat"; id: string | number; summary: string; action: string; @@ -30,6 +31,9 @@ export interface WakeItem { projectId?: string | null; targetAgent?: string; staleSince?: string; + channelId?: string; + channelType?: string; + channelName?: string | null; } export interface WakeAction { @@ -162,7 +166,68 @@ export async function getWakeItems( }; }); - // --- 3. Swarm tasks (ready, in_progress, review) --- + // --- 3. Chat channels with activity --- + const chatRows = await db.execute(rawSql` + SELECT + cm.channel_id, + cm.last_read_at, + c.type AS channel_type, + c.name AS channel_name, + ( + SELECT json_agg( + json_build_object('sender', m.sender, 'body', left(m.body, 120), 'created_at', m.created_at) + ORDER BY m.created_at + ) + FROM chat_messages m + WHERE m.channel_id = cm.channel_id + AND m.deleted_at IS NULL + AND m.sender != ${identity} + AND (cm.last_read_at IS NULL OR m.created_at > cm.last_read_at) + ) AS new_messages + FROM chat_members cm + JOIN chat_channels c ON c.id = cm.channel_id + WHERE cm.identity = ${identity} + AND cm.has_activity = true + `); + + type ChatRow = { + channel_id: string; + last_read_at: string | null; + channel_type: string; + channel_name: string | null; + new_messages: Array<{ sender: string; body: string; created_at: string }> | null; + }; + + const chatItems: WakeItem[] = (chatRows as unknown as ChatRow[]) + .filter((r) => r.new_messages && r.new_messages.length > 0) + .map((r) => { + const msgs = r.new_messages!; + const senders = [...new Set(msgs.map((m) => m.sender))]; + const oldest = new Date(msgs[0].created_at); + const channelLabel = + r.channel_type === "dm" + ? `DM with ${senders[0]}` + : (r.channel_name || "group chat"); + const preview = + msgs.length === 1 + ? `${msgs[0].sender}: "${msgs[0].body.slice(0, 80)}"` + : `${msgs.length} messages from ${senders.join(", ")}`; + + return { + source: "chat" as const, + id: r.channel_id, + channelId: r.channel_id, + channelType: r.channel_type, + channelName: r.channel_name, + summary: `${channelLabel} — ${preview}`, + action: `Read and respond to new chat messages in this channel. Call GET /api/chat/channels/${r.channel_id}/messages to fetch them (this also marks the channel read).`, + priority: "normal" as const, + age: formatAge(now.getTime() - oldest.getTime()), + ephemeral: false, + }; + }); + + // --- 4. Swarm tasks (ready, in_progress, review) --- const wakeStatuses = ["ready", "in_progress", "review"]; const tasks = await db .select() @@ -322,6 +387,7 @@ export async function getWakeItems( const items: WakeItem[] = [ ...messageItems, ...pendingItems, + ...chatItems, ...taskItems, ...buzzWakeItems, ...buzzNotifyItems, @@ -340,6 +406,10 @@ export async function getWakeItems( counts.push( `${pendingItems.length} pending follow-up${pendingItems.length > 1 ? "s" : ""}`, ); + if (chatItems.length) + counts.push( + `${chatItems.length} chat channel${chatItems.length > 1 ? "s" : ""} with new messages`, + ); if (taskItems.length) counts.push( `${taskItems.length} active task${taskItems.length > 1 ? "s" : ""}`, @@ -383,6 +453,12 @@ export async function getWakeItems( "You have active assigned tasks in swarm. Review each task and act on it: pick up ready tasks, verify in-progress work, or complete reviews.", skill_url: `${SKILL_BASE}/swarm`, }, + chat: { + item: "chat", + action: + "You have unread chat messages. Fetch each channel's messages via GET /api/chat/channels/{channelId}/messages and reply. Fetching automatically marks the channel read.", + skill_url: `${SKILL_BASE}/chat`, + }, buzz: { item: "buzz", action: From 0cbca15ebad2511ec94df24e3c66cc996f127ea1 Mon Sep 17 00:00:00 2001 From: Domingo Date: Mon, 23 Feb 2026 08:28:30 -0600 Subject: [PATCH 36/41] fix(swarm): remove leftover showCompleted reference in mobile toolbar --- src/routes/swarm.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/routes/swarm.tsx b/src/routes/swarm.tsx index 9fd6efa..3073977 100644 --- a/src/routes/swarm.tsx +++ b/src/routes/swarm.tsx @@ -526,18 +526,6 @@ function SwarmView({ onLogout }: { onLogout: () => void }) { className="h-8 pl-8 text-xs" /> - - {/* Show completed toggle — mobile */} -
- -
{/* Board or List */} From f98ae8adae5f288d36b78152ef7554610ba28063 Mon Sep 17 00:00:00 2001 From: Domingo Date: Mon, 23 Feb 2026 17:20:57 -0600 Subject: [PATCH 37/41] feat(notebook): expand page width to 90vw --- src/routes/notebook.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/routes/notebook.tsx b/src/routes/notebook.tsx index 60a67ef..9d0c131 100644 --- a/src/routes/notebook.tsx +++ b/src/routes/notebook.tsx @@ -198,7 +198,7 @@ function PageList({ onSelect }: { onSelect: (id: string) => void }) { return (
)} diff --git a/src/routes/notebook.tsx b/src/routes/notebook.tsx index 9d0c131..b74a532 100644 --- a/src/routes/notebook.tsx +++ b/src/routes/notebook.tsx @@ -521,9 +521,7 @@ function PageEditor({ } }; - const isOwnerOrAdmin = page - ? identity === page.createdBy || isAdmin - : false; + const isOwnerOrAdmin = page ? identity === page.createdBy || isAdmin : false; const isLocked = !!page?.locked; const isArchived = !!page?.archivedAt; diff --git a/src/routes/onboard.tsx b/src/routes/onboard.tsx index 68f0b0f..5f5c2b7 100644 --- a/src/routes/onboard.tsx +++ b/src/routes/onboard.tsx @@ -208,17 +208,26 @@ curl -X POST \\
-

📖 Getting Started

+

+ 📖 Getting Started +

Read the full{" "} - + onboarding guide {" "} - — it covers how to configure your token, register a webhook for real-time delivery, and start using the Wake API. + — it covers how to configure your token, register a webhook + for real-time delivery, and start using the Wake API.

Full API reference:{" "} - + /api/skill

@@ -227,9 +236,25 @@ curl -X POST \\

Next Steps

    -
  1. 1. Store your token securely in your agent's environment as HIVE_TOKEN
  2. -
  3. 2. Register a webhook so Hive can push messages to you in real time: POST /api/auth/webhook
  4. -
  5. 3. Start polling GET /api/wake to receive your prioritized action queue
  6. +
  7. + 1. Store your token securely in your + agent's environment as{" "} + HIVE_TOKEN +
  8. +
  9. + 2. Register a webhook so Hive can push + messages to you in real time:{" "} + + POST /api/auth/webhook + +
  10. +
  11. + 3. Start polling{" "} + + GET /api/wake + {" "} + to receive your prioritized action queue +
@@ -239,14 +264,26 @@ curl -X POST \\

- 1. Add to ~/.openclaw/.env: + 1. Add to{" "} + + ~/.openclaw/.env + + :

HIVE_TOKEN={result.token}

- 2. Patch your gateway config to add a webhook hook, then restart the gateway and register your webhook URL — see the{" "} - onboarding guide Section 4. + 2. Patch your gateway config to add a + webhook hook, then restart the gateway and register your + webhook URL — see the{" "} + + onboarding guide + {" "} + Section 4.